refactor(livetv): reuse standard hub cards

This commit is contained in:
edde746
2026-07-12 22:11:30 +02:00
parent 098a472670
commit b9f828ae88
5 changed files with 347 additions and 394 deletions
+47 -380
View File
@@ -5,30 +5,19 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/dpad_select_long_press_controller.dart';
import '../../../focus/key_event_utils.dart';
import '../../../focus/locked_hub_controller.dart';
import '../../../focus/hub_vertical_navigation.dart';
import '../../../i18n/strings.g.dart';
import '../../../media/media_hub.dart';
import '../../../media/media_item.dart';
import '../../../media/media_item_types.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_hub_result.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../services/settings_service.dart';
import '../../../widgets/settings_builder.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/provider_extensions.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focus_builders.dart';
import '../../../widgets/hub_section.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/horizontal_scroll_with_arrows.dart';
import '../../../widgets/optimized_media_image.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../live_tv_actions_mixin.dart';
import '../live_tv_show_schedule_screen.dart';
@@ -45,11 +34,11 @@ class WhatsOnTab extends StatefulWidget {
class WhatsOnTabState extends State<WhatsOnTab>
with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin, WidgetsBindingObserver {
List<LiveTvHubResult> _hubs = [];
List<_WhatsOnHub> _hubs = [];
bool _isLoading = true;
Timer? _refreshTimer;
final Map<String, GlobalKey<_LiveTvHubSectionState>> _hubKeysById = {};
List<GlobalKey<_LiveTvHubSectionState>> _hubKeys = [];
final Map<String, GlobalKey<HubSectionState>> _hubKeysById = {};
List<GlobalKey<HubSectionState>> _hubKeys = [];
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appResumed = true;
@@ -112,7 +101,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allHubs = <LiveTvHubResult>[];
final allHubs = <_WhatsOnHub>[];
final allHubIds = <String>[];
final queriedServers = <String>{};
@@ -125,7 +114,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
final hubs = await client.getLiveTvHubs();
for (final hub in hubs) {
allHubs.add(hub);
allHubs.add(_WhatsOnHub.fromResult(hub));
allHubIds.add('${serverInfo.serverId}\u0000${hub.hubKey}');
}
} catch (e) {
@@ -138,9 +127,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
_hubKeysById.removeWhere((id, _) => !hubIds.contains(id));
setState(() {
_hubs = allHubs;
_hubKeys = [
for (final hubId in allHubIds) _hubKeysById.putIfAbsent(hubId, () => GlobalKey<_LiveTvHubSectionState>()),
];
_hubKeys = [for (final hubId in allHubIds) _hubKeysById.putIfAbsent(hubId, () => GlobalKey<HubSectionState>())];
_isLoading = false;
});
} catch (e) {
@@ -212,17 +199,25 @@ class WhatsOnTabState extends State<WhatsOnTab>
clipBehavior: Clip.none,
itemCount: _hubs.length,
itemBuilder: (context, index) {
return _LiveTvHubSection(
final hub = _hubs[index];
return HubSection(
key: _hubKeys[index],
hub: _hubs[index],
onTap: _onItemTap,
onLongPress: (entry) => showProgramDetails(
program: entry.program,
channel: findChannelForProgram(entry.program),
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
posterServerId: entry.metadata.serverId,
),
hub: hub.mediaHub,
icon: Symbols.live_tv_rounded,
cardSizing: HubCardSizing.grid,
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
onItemTap: (item) => _onItemTap(hub.entryFor(item)),
onItemLongPress: (item) {
final entry = hub.entryFor(item);
showProgramDetails(
program: entry.program,
channel: findChannelForProgram(entry.program),
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
posterServerId: entry.metadata.serverId,
);
},
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
onNavigateToSidebar: widget.onBack,
onBack: widget.onBack,
);
},
@@ -231,360 +226,32 @@ class WhatsOnTabState extends State<WhatsOnTab>
}
}
// Uses locked focus pattern: single Focus node at hub level, visual index in state.
class _WhatsOnHub {
final MediaHub mediaHub;
final Map<MediaItem, LiveTvHubEntry> _entriesByItem;
class _LiveTvHubSection extends StatefulWidget {
final LiveTvHubResult hub;
final void Function(LiveTvHubEntry) onTap;
final void Function(LiveTvHubEntry) onLongPress;
final bool Function(bool isUp)? onVerticalNavigation;
final VoidCallback? onBack;
const _WhatsOnHub._(this.mediaHub, this._entriesByItem);
const _LiveTvHubSection({
super.key,
required this.hub,
required this.onTap,
required this.onLongPress,
this.onVerticalNavigation,
this.onBack,
});
@override
State<_LiveTvHubSection> createState() => _LiveTvHubSectionState();
}
class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetStateMixin {
late FocusNode _hubFocusNode;
final ScrollController _scrollController = ScrollController();
int _focusedIndex = 0;
double _itemExtent = 0;
static const double _leadingPadding = 12.0;
final _selectLongPress = DpadSelectLongPressController();
final SliverChildMemo<LiveTvHubEntry> _childMemo = SliverChildMemo<LiveTvHubEntry>();
@override
void initState() {
super.initState();
_hubFocusNode = FocusNode(debugLabel: 'livetv_hub_${widget.hub.hubKey}');
_hubFocusNode.addListener(_onFocusChange);
}
@override
void didUpdateWidget(_LiveTvHubSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.hub.entries.length != oldWidget.hub.entries.length) {
final maxIndex = widget.hub.entries.isEmpty ? 0 : widget.hub.entries.length - 1;
if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex;
}
}
}
@override
void dispose() {
_selectLongPress.dispose();
_hubFocusNode.removeListener(_onFocusChange);
_hubFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onFocusChange() {
if (!_hubFocusNode.hasFocus) {
_selectLongPress.reset();
}
// ignore: no-empty-block - setState triggers rebuild to update focus styling
setStateIfMounted(() {});
}
void requestFocusAt(int index) {
if (widget.hub.entries.isEmpty) return;
final clamped = index.clamp(0, widget.hub.entries.length - 1);
_focusedIndex = clamped;
HubFocusMemory.setForHub(widget.hub.hubKey, clamped);
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
// ignore: no-empty-block - setState triggers rebuild to update focus styling
setStateIfMounted(() {});
_scrollHubIntoView();
}
void requestFocusFromMemory() {
final index = HubFocusMemory.getForHub(widget.hub.hubKey, widget.hub.entries.length);
requestFocusAt(index);
}
void _scrollHubIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.3,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
void _scrollToIndex(int index, {bool animate = true}) {
scrollListToIndex(
_scrollController,
index,
itemExtent: _itemExtent,
leadingPadding: _leadingPadding,
animate: animate,
);
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final selectResult = _selectLongPress.handleKeyEvent(
event,
isOwnerActive: () => mounted,
onShortPress: _activateCurrentItem,
onLongPress: _activateLongPress,
);
if (selectResult != KeyEventResult.ignored) return selectResult;
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
factory _WhatsOnHub.fromResult(LiveTvHubResult result) {
final entriesByItem = Map<MediaItem, LiveTvHubEntry>.identity();
for (final entry in result.entries) {
entriesByItem[entry.metadata] = entry;
}
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final itemCount = widget.hub.entries.length;
if (itemCount == 0) return KeyEventResult.ignored;
if (key.isLeftKey) {
if (_focusedIndex > 0) {
setState(() {
_focusedIndex--;
});
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_scrollToIndex(_focusedIndex);
} else {
widget.onBack?.call();
}
return KeyEventResult.handled;
}
if (key.isRightKey) {
if (_focusedIndex < itemCount - 1) {
setState(() {
_focusedIndex++;
});
HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex);
_scrollToIndex(_focusedIndex);
}
return KeyEventResult.handled;
}
if (key.isUpKey) {
widget.onVerticalNavigation?.call(true);
return KeyEventResult.handled;
}
if (key.isDownKey) {
widget.onVerticalNavigation?.call(false);
return KeyEventResult.handled;
}
if (key.isContextMenuKey) {
_activateLongPress();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _activateCurrentItem() {
if (_focusedIndex >= widget.hub.entries.length) return;
widget.onTap(widget.hub.entries[_focusedIndex]);
}
void _activateLongPress() {
if (_focusedIndex >= widget.hub.entries.length) return;
widget.onLongPress(widget.hub.entries[_focusedIndex]);
}
void _onItemTapped(int index) {
setState(() {
_focusedIndex = index;
});
HubFocusMemory.setForHub(widget.hub.hubKey, index);
_hubFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final hasFocus = _hubFocusNode.hasFocus;
return SettingValueBuilder<int>(
pref: SettingsService.libraryDensity,
builder: (context, libraryDensity, _) => _buildContent(context, hasFocus, libraryDensity),
);
}
Widget _buildContent(BuildContext context, bool hasFocus, int libraryDensity) {
return Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
mainAxisSize: .min,
children: [
const AppIcon(Symbols.live_tv_rounded, fill: 1),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.hub.title,
style: Theme.of(context).textTheme.titleLarge,
overflow: .ellipsis,
maxLines: 1,
),
),
],
),
),
// Horizontal cards with locked focus control
if (widget.hub.entries.isNotEmpty)
Focus(
focusNode: _hubFocusNode,
onKeyEvent: _handleKeyEvent,
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = GridSizeCalculator.getCellWidth(constraints.maxWidth, context, libraryDensity);
final posterWidth = cardWidth - 16;
final posterHeight = posterWidth * 1.5; // 2:3 aspect
final containerHeight = posterHeight + 66;
_itemExtent = cardWidth + 4;
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
itemCount: widget.hub.entries.length,
itemBuilder: (context, index) {
final entry = widget.hub.entries[index];
final isItemFocused = hasFocus && index == _focusedIndex;
return _childMemo.widgetFor(
index,
entry,
epoch: (cardWidth, posterHeight, widget.hub.entries.length),
salt: isItemFocused,
build: () => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _LiveTvPosterCard(
entry: entry,
width: cardWidth,
posterHeight: posterHeight,
isFocused: isItemFocused,
onTap: () {
_onItemTapped(index);
widget.onTap(entry);
},
onLongPress: () {
_onItemTapped(index);
widget.onLongPress(entry);
},
),
),
);
},
),
),
);
},
),
),
],
);
}
}
class _LiveTvPosterCard extends StatelessWidget {
final LiveTvHubEntry entry;
final double width;
final double posterHeight;
final bool isFocused;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _LiveTvPosterCard({
required this.entry,
required this.width,
required this.posterHeight,
required this.isFocused,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final metadata = entry.metadata;
// Always use poster image: show poster for episodes, thumb for others
final posterImage = metadata.grandparentThumbPath ?? metadata.thumbPath;
return FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: .start,
children: [
SizedBox(
width: double.infinity,
height: posterHeight,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage.poster(
client: context.tryGetMediaClientWithFallback(serverIdOrNull(metadata.serverId)),
imagePath: posterImage,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 4),
Text(
metadata.displayTitle,
maxLines: 1,
overflow: .ellipsis,
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
),
if (metadata.displaySubtitle != null)
Text(
metadata.displaySubtitle!,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1),
),
],
),
),
final firstMetadata = result.entries.isEmpty ? null : result.entries.first.metadata;
return _WhatsOnHub._(
MediaHub(
id: result.hubKey,
title: result.title,
type: 'mixed',
items: [for (final entry in result.entries) entry.metadata],
size: result.entries.length,
serverId: firstMetadata?.serverId,
serverName: firstMetadata?.serverName,
),
entriesByItem,
);
}
LiveTvHubEntry entryFor(MediaItem item) => _entriesByItem[item]!;
}
+46 -3
View File
@@ -57,6 +57,12 @@ class HubSection extends StatefulWidget {
/// Reports the current focused media item. Used by TV spotlight layouts.
final ValueChanged<MediaItem>? onFocusedItemChanged;
/// Overrides the default media navigation for an item.
final ValueChanged<MediaItem>? onItemTap;
/// Overrides the standard media context menu for an item.
final ValueChanged<MediaItem>? onItemLongPress;
/// Callback for vertical navigation (up/down). Return true if handled.
final bool Function(bool isUp)? onVerticalNavigation;
@@ -79,6 +85,9 @@ class HubSection extends StatefulWidget {
/// Controls whether cards follow top-level shelf or grid geometry.
final HubCardSizing cardSizing;
/// Overrides the global episode artwork mode for this hub.
final EpisodePosterMode? episodePosterModeOverride;
/// Vertical viewport alignment when this hub is focused.
final double focusScrollAlignment;
@@ -93,12 +102,15 @@ class HubSection extends StatefulWidget {
this.showServerName = false,
this.loadMoreItems,
this.onFocusedItemChanged,
this.onItemTap,
this.onItemLongPress,
this.onVerticalNavigation,
this.onBack,
this.onNavigateUp,
this.onNavigateToSidebar,
this.inset = false,
this.cardSizing = HubCardSizing.shelf,
this.episodePosterModeOverride,
this.focusScrollAlignment = 0.3,
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@@ -345,12 +357,21 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
}
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
if (widget.onItemTap case final onItemTap?) {
onItemTap(item);
return;
}
_navigateToItem(item);
}
void _showContextMenuForCurrentItem() {
// No context menu for the "View All" card
if (_focusedIndex >= widget.hub.items.length) return;
final item = widget.hub.items[_focusedIndex];
if (widget.onItemLongPress case final onItemLongPress?) {
onItemLongPress(item);
return;
}
_mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu();
}
@@ -396,7 +417,11 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
).textTheme.titleLarge?.copyWith(fontSize: isTv ? 26 : null, fontWeight: isTv ? FontWeight.w700 : null);
return Padding(
padding: .only(bottom: isTv && !widget.inset ? TvLayoutConstants.shelfVerticalGap : 0),
padding: .only(
bottom: isTv && !widget.inset && widget.cardSizing == HubCardSizing.shelf
? TvLayoutConstants.shelfVerticalGap
: 0,
),
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
@@ -465,7 +490,8 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
? _getTvCardWidth(constraints.maxWidth, density, leadingPadding)
: GridSizeCalculator.getCellWidth(constraints.maxWidth, context, density);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final EpisodePosterMode episodePosterMode =
widget.episodePosterModeOverride ?? svc.read(SettingsService.episodePosterMode);
final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
@@ -506,6 +532,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
posterHeight,
useWideLayout,
isMixedHub,
episodePosterMode,
isKeyboardMode,
widget.inset,
widget.isInContinueWatching,
@@ -621,7 +648,10 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
// building a second dead gesture-detector stack per card.
onTap: isKeyboardMode ? () => _onItemTapped(index) : null,
onLongPress: isKeyboardMode
? () => _mediaCardKeys[index]?.currentState?.showContextMenu()
? () {
_onItemTapped(index);
_mediaCardKeys[index]?.currentState?.showContextMenu();
}
: null,
delegateFocusBorder: true,
child: MediaCard(
@@ -631,10 +661,23 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
height: posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
onTap: widget.onItemTap == null
? null
: () {
_onItemTapped(index);
widget.onItemTap!(item);
},
onLongPress: widget.onItemLongPress == null
? null
: () {
_onItemTapped(index);
widget.onItemLongPress!(item);
},
forceGridMode: true,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
mixedHubContext: isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
),
),
+46 -11
View File
@@ -124,6 +124,11 @@ class MediaCard extends StatefulWidget {
final void Function(String itemId)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching;
final VoidCallback? onListRefresh; // Callback to refresh the entire parent list
/// Overrides the card's default media navigation for specialized surfaces.
final VoidCallback? onTap;
/// Overrides the standard media context menu for every long-press path.
final VoidCallback? onLongPress;
final bool forceGridMode;
final bool forceListMode;
final bool isInContinueWatching;
@@ -143,6 +148,8 @@ class MediaCard extends StatefulWidget {
this.onRefresh,
this.onRemoveFromContinueWatching,
this.onListRefresh,
this.onTap,
this.onLongPress,
this.forceGridMode = false,
this.forceListMode = false,
this.isInContinueWatching = false,
@@ -175,6 +182,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
// MediaContextMenu, which is not in their tree.
@override
void showContextMenuFromTap() {
if (widget.onLongPress case final onLongPress?) {
onLongPress();
return;
}
final catalogItem = _catalogItem;
if (catalogItem != null) {
unawaited(showCatalogItemMenu(context, catalogItem, position: lastTapPosition));
@@ -185,6 +196,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
@override
void showContextMenu() {
if (widget.onLongPress case final onLongPress?) {
onLongPress();
return;
}
final catalogItem = _catalogItem;
if (catalogItem != null) {
unawaited(showCatalogItemMenu(context, catalogItem));
@@ -208,6 +223,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
if (contextMenuKey.currentState?.isContextMenuOpen == true) {
return;
}
if (widget.onTap case final onTap?) {
onTap();
return;
}
final result = await navigateToMediaItem(
context,
@@ -290,13 +309,14 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
localPosterPath: localPosterPath,
showServerName: widget.showServerName,
episodePosterModeOverride: widget.episodePosterModeOverride,
enableDetailLinks: widget.onTap == null,
);
// Catalog stand-ins (Explore tab) have no server-backed actions — every
// entry in the context menu would break on serverId == null. Long-press
// no-ops on them; taps route through the catalog branch in
// navigateToMediaItem.
if (item is MediaItem && item.isCatalogItem) return cardWidget;
if ((item is MediaItem && item.isCatalogItem) || widget.onLongPress != null) return cardWidget;
// MediaContextMenu as a non-widget helper — only wrap with its key for
// programmatic context menu access; gesture callbacks are on InkWell directly.
@@ -435,7 +455,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
Expanded(child: poster),
const SizedBox(height: 2),
// Title (flattened — no inner Column)
if (item is MediaItem && _hasClickableTitle(item))
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
_ClickableText(
text: item.displayTitle,
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
@@ -452,7 +472,12 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
if (item is MediaPlaylist)
_MediaCardHelpers.buildPlaylistMeta(context, item)
else if (item is MediaItem)
_MediaCardHelpers.buildMetadataSubtitle(context, item, isOffline: widget.isOffline),
_MediaCardHelpers.buildMetadataSubtitle(
context,
item,
isOffline: widget.isOffline,
enableDetailLinks: widget.onTap == null,
),
],
),
),
@@ -475,6 +500,7 @@ class _MediaCardList extends StatelessWidget {
final String? localPosterPath;
final bool showServerName;
final EpisodePosterMode? episodePosterModeOverride;
final bool enableDetailLinks;
const _MediaCardList({
required this.item,
@@ -489,6 +515,7 @@ class _MediaCardList extends StatelessWidget {
this.localPosterPath,
this.showServerName = false,
this.episodePosterModeOverride,
required this.enableDetailLinks,
});
CardShape _cardShape() {
@@ -622,11 +649,14 @@ class _MediaCardList extends StatelessWidget {
final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : '';
return Row(
children: [
_ClickableText(
text: 'S${mi.parentIndex}',
style: style,
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
),
if (enableDetailLinks)
_ClickableText(
text: 'S${mi.parentIndex}',
style: style,
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
)
else
Text('S${mi.parentIndex}', style: style),
Text('$episodeNum · ', style: style),
Expanded(
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style),
@@ -684,7 +714,7 @@ class _MediaCardList extends StatelessWidget {
crossAxisAlignment: .start,
mainAxisAlignment: .start,
children: [
if (item is MediaItem && _hasClickableTitle(item as MediaItem))
if (enableDetailLinks && item is MediaItem && _hasClickableTitle(item as MediaItem))
_ClickableText(
text: (item as MediaItem).displayTitle,
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
@@ -935,7 +965,12 @@ class _MediaCardHelpers {
}
/// Builds metadata subtitle (for collections, episodes, movies, shows)
static Widget buildMetadataSubtitle(BuildContext context, MediaItem mi, {bool isOffline = false}) {
static Widget buildMetadataSubtitle(
BuildContext context,
MediaItem mi, {
bool isOffline = false,
bool enableDetailLinks = true,
}) {
final subtitleStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1);
@@ -971,7 +1006,7 @@ class _MediaCardHelpers {
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : '';
if (mi.parentId != null) {
if (enableDetailLinks && mi.parentId != null) {
return Row(
children: [
_ClickableText(