fix(tv): focus late Continue Watching on startup

close #1602
This commit is contained in:
edde746
2026-07-25 10:54:43 +02:00
parent 0959cd3040
commit 73f4a3e055
4 changed files with 222 additions and 4 deletions
+1
View File
@@ -1150,6 +1150,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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),
+21 -3
View File
@@ -430,6 +430,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
if (event is KeyUpEvent) _clearSelectSuppression();
return KeyEventResult.handled;
}
_hasUserInteracted = true;
return _selectLongPress.handleKeyEvent(
event,
@@ -711,7 +721,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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<TvBrowseRail> {
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;
+123 -1
View File
@@ -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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<DiscoverProvider>.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<TvSpotlightBackground>(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<TvSpotlightBackground>(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<MediaHub> hubs;
final List<MediaItem> continueWatching;
List<MediaItem> continueWatching;
_FakeMediaServerClient({required this.hubs, this.continueWatching = const []});
+77
View File
@@ -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<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,
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 = <String>[];