fix(tv): keep focus on the played item when continue watching reorders

Exiting the player after enough progress moved the played item to the front of Continue Watching left D-pad focus at its old position, now occupied by a completely different item.

TvBrowseRail and HubSection now remap the focused index to follow the focused item identity when a hub reorders underneath it, falling back to the same series replacement entry when a finished episode is swapped for the next one, and HubSection focus memory is remapped so re-entering the row lands on the same item.

close #1987
This commit is contained in:
edde746
2026-08-18 19:27:00 +02:00
parent 2a6f752860
commit c198bab04c
7 changed files with 290 additions and 4 deletions
+8
View File
@@ -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;
}
}
+14
View File
@@ -95,3 +95,17 @@ List<String> _hubKeyTokens(String rawKey) {
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '');
bool _hasTailToken(List<String> 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<MediaItem> 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);
}
+27
View File
@@ -170,6 +170,10 @@ class HubSectionState extends State<HubSection> 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<HubSection> 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<MediaItem> 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();
+19 -4
View File
@@ -462,9 +462,11 @@ class TvBrowseRailState extends State<TvBrowseRail> 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<TvBrowseRail> 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<TvBrowseRail> 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) {
@@ -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);
});
}
+59
View File
@@ -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<MediaItem> hubItems) => MediaHub(
id: 'continue_watching',
title: 'Continue Watching',
type: 'mixed',
items: hubItems,
size: hubItems.length,
);
final memory = HubFocusMemory();
final hubKey = GlobalKey<HubSectionState>();
String? focusedItemId;
Future<void> mount(List<MediaItem> 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) {
+144
View File
@@ -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<MediaItem> items) =>
MediaHub(id: 'continue_watching', title: 'Continue Watching', type: 'mixed', items: items, size: items.length);
final focusedItemIds = <String>[];
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>.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<MediaItem> items) =>
MediaHub(id: 'continue_watching', title: 'Continue Watching', type: 'mixed', items: items, size: items.length);
final focusedItemIds = <String>[];
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>.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) {