From 73f4a3e055692b9273cb410fcec24f36549c38af Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:54:34 +0200 Subject: [PATCH] fix(tv): focus late Continue Watching on startup close #1602 --- lib/screens/discover_screen.dart | 1 + lib/widgets/tv_browse_rail.dart | 24 ++++- test/screens/discover_screen_test.dart | 124 ++++++++++++++++++++++++- test/widgets/tv_browse_rail_test.dart | 77 +++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 39f5aaae..53303907 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1150,6 +1150,7 @@ class _DiscoverScreenState extends State return _tvBrowseRailWidget = TvBrowseRail( key: _tvBrowseRailKey, hubs: browseHubs, + initialHubId: 'continue_watching', focusMemory: _hubFocusMemory, showServerName: showServerName, iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 1ae199f9..6c57e1e0 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -430,6 +430,7 @@ class TvBrowseRailState extends State { Timer? _selectSuppressionMaxTimer; VoidCallback? _gestureSignalListener; bool _suppressSelectUntilKeyUp = false; + bool _hasUserInteracted = false; bool _hasUserChangedHub = false; int _verticalScrollGeneration = 0; bool _hasUserChangedItem = false; @@ -670,7 +671,13 @@ class TvBrowseRailState extends State { bool _selectInitialHubIfPossible() { final initialHubId = widget.initialHubId; - if (_hasUserChangedHub || initialHubId == null || widget.hubs.isEmpty) return false; + if (_hasUserInteracted || + _hasUserChangedHub || + _hasUserChangedItem || + initialHubId == null || + widget.hubs.isEmpty) { + return false; + } // External contract: `initialHubId` is a bare `hub.id` supplied by the // single-server media-detail caller, so match on `hub.id` (not `_hubKey`). final initialIndex = widget.hubs.indexWhere((hub) => hub.id == initialHubId); @@ -685,7 +692,9 @@ class TvBrowseRailState extends State { bool _selectInitialItemIfPossible() { final initialItemId = widget.initialItemId; final hub = _activeHub; - if (_hasUserChangedHub || _hasUserChangedItem || initialItemId == null || hub == null) return false; + if (_hasUserInteracted || _hasUserChangedHub || _hasUserChangedItem || initialItemId == null || hub == null) { + return false; + } final initialIndex = hub.items.indexWhere((item) => item.id == initialItemId); if (initialIndex == -1) return false; if (initialIndex != _itemIndex) _itemIndex = initialIndex; @@ -700,6 +709,7 @@ class TvBrowseRailState extends State { if (event is KeyUpEvent) _clearSelectSuppression(); return KeyEventResult.handled; } + _hasUserInteracted = true; return _selectLongPress.handleKeyEvent( event, @@ -711,7 +721,10 @@ class TvBrowseRailState extends State { if (widget.onBack != null) { final backResult = handleBackKeyAction(event, widget.onBack!); - if (backResult != KeyEventResult.ignored) return backResult; + if (backResult != KeyEventResult.ignored) { + _hasUserInteracted = true; + return backResult; + } } if (key.isDpadDirection && event is KeyUpEvent) return KeyEventResult.handled; @@ -719,6 +732,7 @@ class TvBrowseRailState extends State { if (!event.isActionable) return KeyEventResult.ignored; final hub = _activeHub; if (hub == null) return KeyEventResult.ignored; + if (key.isDpadDirection || key.isContextMenuKey) _hasUserInteracted = true; if (key.isLeftKey) { if (_itemIndex > 0) { @@ -761,6 +775,7 @@ class TvBrowseRailState extends State { void _moveItem(int delta, {Duration duration = _navigationScrollDuration}) { final hub = _activeHub; if (hub == null) return; + _hasUserInteracted = true; final next = (_itemIndex + delta).clamp(0, _totalItemCount(hub) - 1); if (next == _itemIndex) return; @@ -776,6 +791,7 @@ class TvBrowseRailState extends State { void _moveHub(int delta) { if (widget.hubs.isEmpty) return; + _hasUserInteracted = true; final next = (_hubIndex + delta).clamp(0, widget.hubs.length - 1); if (next == _hubIndex) return; final currentHub = _activeHub; @@ -858,6 +874,7 @@ class TvBrowseRailState extends State { if (active == null || _hubKey(active) != _hubKey(hub) || index >= hub.items.length || _itemIndex == index) { return; } + _hasUserInteracted = true; _itemIndex = index; _hasUserChangedItem = true; _focusModel.set(_hubIndex, _itemIndex); @@ -868,6 +885,7 @@ class TvBrowseRailState extends State { void _selectHubItem(MediaHub hub, int hubIndex, int itemIndex) { final totalCount = _totalItemCount(hub); if (totalCount == 0) return; + _hasUserInteracted = true; final clampedItemIndex = itemIndex.clamp(0, totalCount - 1).toInt(); final hubChanged = _hubIndex != hubIndex; diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index d7c7e49e..5685ace4 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -239,6 +239,128 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); + testWidgets('TV selects Continue Watching when it arrives after recommendation hubs', (tester) async { + await SettingsService.getInstance(); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final recommendedItem = testMediaItem( + id: 'recommended', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Recommended', + serverId: 'server_1', + serverName: 'Server', + ); + final continueItem = testMediaItem( + id: 'continue', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Continue Watching', + serverId: 'server_1', + serverName: 'Server', + ); + final recommendedHub = MediaHub( + id: 'recommended_hub', + title: 'Recommended', + type: 'movie', + items: [recommendedItem], + size: 1, + ); + final client = _FakeMediaServerClient(hubs: [recommendedHub]); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + final librariesProvider = LibrariesProvider(); + final watchTogetherProvider = WatchTogetherProvider(); + final companionRemoteProvider = CompanionRemoteProvider(); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final profileRegistry = _FakeProfileRegistry(db); + final connectionRegistry = _FakeConnectionRegistry(db); + final profileConnectionRegistry = _FakeProfileConnectionRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connectionRegistry, + profileConnections: profileConnectionRegistry, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: profileRegistry, + plexHome: plexHome, + connections: connectionRegistry, + storage: storage, + ); + final discoverProvider = DiscoverProvider( + multiServerProvider, + hiddenLibrariesProvider, + librariesProvider, + profileId: null, + isProfileBinding: () => activeProfileProvider.isBinding, + ); + const foregroundWidth = 1280 - SideNavigationRailState.tvCollapsedWidth; + + addTearDown(() async { + discoverProvider.dispose(); + activeProfileProvider.dispose(); + companionRemoteProvider.dispose(); + watchTogetherProvider.dispose(); + librariesProvider.dispose(); + hiddenLibrariesProvider.dispose(); + multiServerProvider.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: watchTogetherProvider), + ChangeNotifierProvider.value(value: companionRemoteProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: discoverProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: SideNavigationRailState.expandedWidth, + reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, + foregroundLeft: 0, + foregroundWidth: foregroundWidth, + viewportWidth: 1280, + child: const SizedBox(width: foregroundWidth, height: 720, child: DiscoverScreen()), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + expect(tester.widget(find.byType(TvSpotlightBackground)).item?.id, recommendedItem.id); + + client.continueWatching = [continueItem]; + await discoverProvider.load(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + expect(tester.widget(find.byType(TvSpotlightBackground)).item?.id, continueItem.id); + }); + testWidgets('non-TV hero keeps indicators visible in keyboard mode and fades to solid bg', (tester) async { TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); @@ -387,7 +509,7 @@ void main() { class _FakeMediaServerClient implements MediaServerClient { final List hubs; - final List continueWatching; + List continueWatching; _FakeMediaServerClient({required this.hubs, this.continueWatching = const []}); diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 00901b5d..74f7e29b 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -1118,6 +1118,83 @@ void main() { expect(activeHubIds.last, 'detail_episodes'); }); + testWidgets('keeps the user selection when a preferred hub arrives after item navigation', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final serverManager = MultiServerManager(); + final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + addTearDown(multiServerProvider.dispose); + + final recentItems = [ + testMediaItem(id: 'recent_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 1'), + testMediaItem(id: 'recent_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 2'), + ]; + final continueItem = testMediaItem( + id: 'continue_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Continue Watching', + ); + final recentHub = MediaHub( + id: 'recently_added', + title: 'Recently Added', + type: 'movie', + items: recentItems, + size: recentItems.length, + ); + final continueHub = MediaHub( + id: 'continue_watching', + title: 'Continue Watching', + type: 'mixed', + items: [continueItem], + size: 1, + ); + final focusedSelections = <(String, String)>[]; + + Widget buildRail(List hubs) { + return ChangeNotifierProvider.value( + value: multiServerProvider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + focusMemory: focusMemory, + key: const ValueKey('rail'), + hubs: hubs, + initialHubId: continueHub.id, + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + onFocusedHubItemChanged: (hub, item) => focusedSelections.add((hub.id, item.id)), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRail([recentHub])); + await tester.pump(); + expect(focusedSelections.last, (recentHub.id, recentItems.first.id)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(focusedSelections.last, (recentHub.id, recentItems.last.id)); + + await tester.pumpWidget(buildRail([continueHub, recentHub])); + await tester.pumpAndSettle(); + + expect(focusedSelections.last, (recentHub.id, recentItems.last.id)); + }); + testWidgets('selects preferred item when active hub items are populated asynchronously', (tester) async { final focusedItemIds = [];