refactor(livetv): reuse standard hub cards
This commit is contained in:
@@ -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]!;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user