diff --git a/lib/focus/locked_hub_controller.dart b/lib/focus/locked_hub_controller.dart index 89dfa3957..a8feecd2a 100644 --- a/lib/focus/locked_hub_controller.dart +++ b/lib/focus/locked_hub_controller.dart @@ -32,4 +32,12 @@ class HubFocusMemory { final remembered = _perHubMemory[hubKey]; return (remembered ?? fallback).clamp(0, itemCount - 1); } + + /// Rewrite a hub's remembered index in place after its content reordered + /// underneath it (a Continue Watching refresh, not user navigation). Unlike + /// [setForHub] this never moves the cross-hub column hint, and it is a no-op + /// for hubs without memory. + void remapForHub(String hubKey, int index) { + if (_perHubMemory.containsKey(hubKey)) _perHubMemory[hubKey] = index; + } } diff --git a/lib/media/media_hub.dart b/lib/media/media_hub.dart index 6d074f019..497300b64 100644 --- a/lib/media/media_hub.dart +++ b/lib/media/media_hub.dart @@ -95,3 +95,17 @@ List _hubKeyTokens(String rawKey) { String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), ''); bool _hasTailToken(List tokens, String token) => tokens.isNotEmpty && tokens.last == token; + +/// Index of [previous] within [items] after a content refresh, so visual +/// focus can follow the item it was on instead of staying at a stale +/// position (a Continue Watching refresh moves the just-played entry to the +/// front). The exact item wins; an episode that left the list falls back to +/// its series' replacement entry (a finished episode becomes the next +/// episode). Returns -1 when neither is present. +int followItemIndex(List items, MediaItem previous) { + final exactIndex = items.indexWhere((item) => item.globalKey == previous.globalKey); + if (exactIndex != -1) return exactIndex; + final seriesKey = previous.seriesGlobalKey; + if (seriesKey == null) return -1; + return items.indexWhere((item) => item.seriesGlobalKey == seriesKey); +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index b05709351..1b1d01344 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -170,6 +170,10 @@ class HubSectionState extends State with MountedSetStateMixin, Skele _mediaCardKeys.removeWhere((index, _) => index >= widget.hub.items.length); } + if (widget.hub.id == oldWidget.hub.id) { + _followFocusedItem(oldWidget.hub.items); + } + if (widget.hub.items.length != oldWidget.hub.items.length || widget.hub.more != oldWidget.hub.more) { final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1; if (_focusedIndex > maxIndex) { @@ -178,6 +182,29 @@ class HubSectionState extends State with MountedSetStateMixin, Skele } } + /// Keep visual focus on the media it was on when the hub's items reorder + /// underneath it — a Continue Watching refresh after playback moves the + /// just-played item to the front (#1987). The exact item wins; an episode + /// that left the row follows its series' replacement entry (next episode). + /// When neither is present the positional clamp above applies unchanged. + void _followFocusedItem(List oldItems) { + if (identical(oldItems, widget.hub.items)) return; + if (_focusedIndex < 0 || _focusedIndex >= oldItems.length) return; + final followedIndex = followItemIndex(widget.hub.items, oldItems[_focusedIndex]); + if (followedIndex == -1 || followedIndex == _focusedIndex) return; + _focusedIndex = followedIndex; + widget.focusMemory.remapForHub(_focusMemoryKey, followedIndex); + if (_hubFocusNode.hasFocus) { + // didUpdateWidget runs during the build phase; notifying listeners or + // jumping scroll positions here could setState mid-build upstream. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusedIndex != followedIndex || !_hubFocusNode.hasFocus) return; + _notifyFocusedItemChanged(); + _scrollToIndex(followedIndex, animate: false); + }); + } + } + @override void dispose() { _selectLongPress.dispose(); diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index b96c56164..a10024433 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -462,9 +462,11 @@ class TvBrowseRailState extends State with TickerProviderStateMixi return; } - final oldActiveHubKey = oldWidget.hubs.isEmpty - ? null - : _hubKey(oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)]); + final oldActiveHub = oldWidget.hubs.isEmpty ? null : oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)]; + final oldActiveHubKey = oldActiveHub == null ? null : _hubKey(oldActiveHub); + final oldFocusedItem = oldActiveHub != null && _itemIndex < oldActiveHub.items.length + ? oldActiveHub.items[_itemIndex] + : null; if (widget.hubs.isEmpty) { _hubIndex = 0; @@ -488,6 +490,19 @@ class TvBrowseRailState extends State with TickerProviderStateMixi final hub = _activeHub; if (hub == null) return; + var followedFocusedItem = false; + if (_hasUserInteracted && oldFocusedItem != null && _hubKey(hub) == oldActiveHubKey) { + // Keep the selection on the media it was on when the active hub's items + // reorder underneath it — a Continue Watching refresh after playback + // moves the just-played item to the front (#1987). When the exact item + // left the list, follow its series' replacement entry (finished episode + // → next episode); otherwise fall through to the positional clamp. + final followedIndex = followItemIndex(hub.items, oldFocusedItem); + if (followedIndex != -1 && followedIndex != _itemIndex) { + _itemIndex = followedIndex; + followedFocusedItem = true; + } + } _itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1); final selectedInitialItem = _selectInitialItemIfPossible(); // notify:false — this runs during the build phase and the enclosing @@ -503,7 +518,7 @@ class TvBrowseRailState extends State with TickerProviderStateMixi _rememberTrailingStates(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (selectedInitialItem) _scrollToItem(animate: false); + if (selectedInitialItem || followedFocusedItem) _scrollToItem(animate: false); if (shouldJumpAlignActiveHub) { _scrollActiveHubToTop(animate: false); } else if (shouldAnimateAlignActiveHub) { diff --git a/test/focus/locked_hub_controller_test.dart b/test/focus/locked_hub_controller_test.dart index f81cfaf27..3ad3e79b3 100644 --- a/test/focus/locked_hub_controller_test.dart +++ b/test/focus/locked_hub_controller_test.dart @@ -24,4 +24,23 @@ void main() { expect(memory.getForHubOnly('unseen', 0, fallback: 9), 0); expect(memory.getForHub('unseen', 0), 0); }); + + test('remapForHub rewrites an existing entry without moving the column hint', () { + final memory = HubFocusMemory(); + + memory.setForHub('continue_watching', 3); + memory.remapForHub('continue_watching', 0); + + expect(memory.getForHubOnly('continue_watching', 5), 0); + // The cross-hub column hint still reflects the user's last navigation. + expect(memory.getForHub('unseen_hub', 5), 3); + }); + + test('remapForHub is a no-op for hubs without memory', () { + final memory = HubFocusMemory(); + + memory.remapForHub('unseen_hub', 4); + + expect(memory.getForHubOnly('unseen_hub', 5, fallback: 1), 1); + }); } diff --git a/test/widgets/hub_section_test.dart b/test/widgets/hub_section_test.dart index a11e1040e..20920d2d7 100644 --- a/test/widgets/hub_section_test.dart +++ b/test/widgets/hub_section_test.dart @@ -221,6 +221,65 @@ void main() { await tester.pumpAndSettle(); expect(focusedItemId, 'item_0'); }); + + testWidgets('visual focus follows the played item when the hub reorders', (tester) async { + final items = [ + for (var index = 0; index < 3; index++) + testMediaItem(id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Item $index'), + ]; + MediaHub hub(List hubItems) => MediaHub( + id: 'continue_watching', + title: 'Continue Watching', + type: 'mixed', + items: hubItems, + size: hubItems.length, + ); + + final memory = HubFocusMemory(); + final hubKey = GlobalKey(); + String? focusedItemId; + + Future mount(List hubItems) async { + await tester.pumpWidget( + InputModeTracker( + child: _TestApp( + child: HubSection( + key: hubKey, + hub: hub(hubItems), + focusMemory: memory, + icon: Symbols.play_circle_rounded, + isInContinueWatching: true, + onFocusedItemChanged: (item) => focusedItemId = item.id, + ), + ), + ), + ); + } + + await mount(items); + hubKey.currentState!.requestFocusAt(1); + await tester.pumpAndSettle(); + expect(focusedItemId, 'item_1'); + + // Playback progress moved the played item to the front (#1987). + await mount([items[1], items[0], items[2]]); + await tester.pumpAndSettle(); + + // Live focus followed to index 0: moving right lands on the neighbor in + // the new order, not two past the stale position. + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(focusedItemId, 'item_0'); + + // The remembered position follows a reorder too: memory now points at + // item_0 (index 1), which the next reorder moves back to index 0. + await mount([items[0], items[1], items[2]]); + await tester.pumpAndSettle(); + hubKey.currentState!.requestFocusFromMemory(); + await tester.pumpAndSettle(); + expect(focusedItemId, 'item_0'); + }); } MediaHub _hubWith(MediaItem item) { diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index ba43a1164..9919a7e37 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -2217,6 +2217,150 @@ void main() { expect(focusedItemIds, isEmpty); expect(activeHubIds, isEmpty); }); + + testWidgets('selection follows the played item when continue watching reorders', (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 = testMultiServerProvider(serverManager); + addTearDown(multiServerProvider.dispose); + + final movieA = testMediaItem(id: 'movie_a', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie A'); + final movieB = testMediaItem(id: 'movie_b', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie B'); + final movieC = testMediaItem(id: 'movie_c', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie C'); + MediaHub continueHub(List items) => + MediaHub(id: 'continue_watching', title: 'Continue Watching', type: 'mixed', items: items, size: items.length); + final focusedItemIds = []; + + 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, + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + onFocusedItemChanged: (item) => focusedItemIds.add(item.id), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildRail([ + continueHub([movieA, movieB, movieC]), + ]), + ); + await tester.pump(); + + // Focus the second item, mirroring the user picking it before playback. + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(focusedItemIds.last, movieB.id); + + // Playback progress moved the played item to the front (#1987). + await tester.pumpWidget( + buildRail([ + continueHub([movieB, movieA, movieC]), + ]), + ); + await tester.pumpAndSettle(); + + expect(focusedItemIds.last, movieB.id); + }); + + testWidgets('selection follows the series when a finished episode is replaced by the next one', (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 = testMultiServerProvider(serverManager); + addTearDown(multiServerProvider.dispose); + + final movie = testMediaItem(id: 'movie_a', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie A'); + final episode1 = testMediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + grandparentId: 'show_1', + title: 'S01E01', + ); + final episode2 = testMediaItem( + id: 'episode_2', + backend: MediaBackend.plex, + kind: MediaKind.episode, + grandparentId: 'show_1', + title: 'S01E02', + ); + MediaHub continueHub(List items) => + MediaHub(id: 'continue_watching', title: 'Continue Watching', type: 'mixed', items: items, size: items.length); + final focusedItemIds = []; + + 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, + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + onFocusedItemChanged: (item) => focusedItemIds.add(item.id), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildRail([ + continueHub([movie, episode1]), + ]), + ); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(focusedItemIds.last, episode1.id); + + // Finishing the episode replaced it with the show's next episode at the + // front of the row. + await tester.pumpWidget( + buildRail([ + continueHub([episode2, movie]), + ]), + ); + await tester.pumpAndSettle(); + + expect(focusedItemIds.last, episode2.id); + }); } ScrollPosition _activeRailPosition(WidgetTester tester) {