diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 0c3df39e..3ac4460e 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -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 with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver { - List _hubs = []; + List<_WhatsOnHub> _hubs = []; bool _isLoading = true; Timer? _refreshTimer; - final Map> _hubKeysById = {}; - List> _hubKeys = []; + final Map> _hubKeysById = {}; + List> _hubKeys = []; bool _refreshRequested = true; bool _tickerEnabled = false; bool _appResumed = true; @@ -112,7 +101,7 @@ class WhatsOnTabState extends State try { final multiServer = context.read(); final liveTvServers = multiServer.liveTvServers; - final allHubs = []; + final allHubs = <_WhatsOnHub>[]; final allHubIds = []; final queriedServers = {}; @@ -125,7 +114,7 @@ class WhatsOnTabState extends State 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 _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())]; _isLoading = false; }); } catch (e) { @@ -212,17 +199,25 @@ class WhatsOnTabState extends State 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 } } -// Uses locked focus pattern: single Focus node at hub level, visual index in state. +class _WhatsOnHub { + final MediaHub mediaHub; + final Map _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 _childMemo = SliverChildMemo(); - - @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.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( - 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]!; } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 88ab8326..3b052719 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -57,6 +57,12 @@ class HubSection extends StatefulWidget { /// Reports the current focused media item. Used by TV spotlight layouts. final ValueChanged? onFocusedItemChanged; + /// Overrides the default media navigation for an item. + final ValueChanged? onItemTap; + + /// Overrides the standard media context menu for an item. + final ValueChanged? 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 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 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 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 with MountedSetStateMixin, Skele posterHeight, useWideLayout, isMixedHub, + episodePosterMode, isKeyboardMode, widget.inset, widget.isInContinueWatching, @@ -621,7 +648,10 @@ class HubSectionState extends State 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 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, ), ), ), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4a1517bb..7f744745 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin _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( diff --git a/test/widgets/hub_section_test.dart b/test/widgets/hub_section_test.dart new file mode 100644 index 00000000..a9e9e68e --- /dev/null +++ b/test/widgets/hub_section_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:plezy/widgets/hub_section.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('custom item callbacks own pointer actions', (tester) async { + final item = testMediaItem( + id: 'pointer_item', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Pointer Movie', + ); + MediaItem? tappedItem; + MediaItem? longPressedItem; + + await tester.pumpWidget( + _TestApp( + child: HubSection( + hub: _hubWith(item), + icon: Symbols.live_tv_rounded, + onItemTap: (value) => tappedItem = value, + onItemLongPress: (value) => longPressedItem = value, + ), + ), + ); + + await tester.tap(find.text('Pointer Movie')); + expect(tappedItem, same(item)); + + await tester.longPress(find.text('Pointer Movie')); + expect(longPressedItem, same(item)); + }); + + testWidgets('custom item callbacks own D-pad actions', (tester) async { + final item = testMediaItem( + id: 'dpad_item', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'D-pad Movie', + ); + final hubKey = GlobalKey(); + MediaItem? tappedItem; + MediaItem? longPressedItem; + + await tester.pumpWidget( + InputModeTracker( + child: _TestApp( + child: HubSection( + key: hubKey, + hub: _hubWith(item), + icon: Symbols.live_tv_rounded, + onItemTap: (value) => tappedItem = value, + onItemLongPress: (value) => longPressedItem = value, + ), + ), + ), + ); + + hubKey.currentState!.requestFocusAt(0); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + expect(tappedItem, same(item)); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + expect(longPressedItem, same(item)); + }); + + testWidgets('grid poster override uses dense 2:3 TV geometry', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final item = testMediaItem( + id: 'poster_episode', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Poster Episode', + parentIndex: 1, + index: 2, + thumbPath: '/episode-thumb.jpg', + grandparentThumbPath: '/series-poster.jpg', + ); + + await tester.pumpWidget( + _TestApp( + child: HubSection( + hub: _hubWith(item), + icon: Symbols.live_tv_rounded, + cardSizing: HubCardSizing.grid, + episodePosterModeOverride: EpisodePosterMode.seriesPoster, + ), + ), + ); + + final mediaCard = tester.widget(find.byType(MediaCard)); + expect(mediaCard.episodePosterModeOverride, EpisodePosterMode.seriesPoster); + + final poster = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first; + final posterSize = tester.getSize(poster); + expect(posterSize.height / posterSize.width, closeTo(1.5, 0.001)); + + final outerPadding = tester.widget( + find.descendant(of: find.byType(HubSection), matching: find.byType(Padding)).first, + ); + expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0); + }); +} + +MediaHub _hubWith(MediaItem item) { + return MediaHub(id: 'live_tv_hub', title: 'Live TV', type: 'mixed', items: [item], size: 1, serverId: item.serverId); +} + +class _TestApp extends StatelessWidget { + final Widget child; + + const _TestApp({required this.child}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold(body: ListView(children: [child])), + ); + } +} diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index b3574e2d..2208c7ce 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -208,6 +208,65 @@ void main() { expect(find.byType(CompositedTransformFollower), findsNothing); }); + testWidgets('custom tap owns pointer and programmatic activation', (tester) async { + final item = testMediaItem( + id: 'custom_tap', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Custom Tap Movie', + ); + final cardKey = GlobalKey(); + var tapCount = 0; + + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 200, + height: 330, + child: MediaCard(key: cardKey, item: item, forceGridMode: true, isOffline: true, onTap: () => tapCount++), + ), + ), + ); + + await tester.tap(find.text('Custom Tap Movie')); + expect(tapCount, 1); + + cardKey.currentState!.handleTap(); + expect(tapCount, 2); + }); + + testWidgets('custom long press owns pointer and programmatic activation', (tester) async { + final item = testMediaItem( + id: 'custom_long_press', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Custom Long Press Movie', + ); + final cardKey = GlobalKey(); + var longPressCount = 0; + + await tester.pumpWidget( + _TestApp( + child: SizedBox( + width: 200, + height: 330, + child: MediaCard( + key: cardKey, + item: item, + forceGridMode: true, + isOffline: true, + onLongPress: () => longPressCount++, + ), + ), + ), + ); + + await tester.longPress(find.text('Custom Long Press Movie')); + expect(longPressCount, 1); + + cardKey.currentState!.showContextMenu(); + expect(longPressCount, 2); + }); } Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed}) {