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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user