diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 4f776a07..7f2fa873 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -196,6 +196,7 @@ class _DiscoverScreenState extends State // Track initial load so we can focus hero when content first appears bool _initialLoadComplete = false; + bool _pendingTvBrowseRailFocus = false; // Hub navigation keys GlobalKey? _continueWatchingHubKey; @@ -316,7 +317,7 @@ class _DiscoverScreenState extends State void _focusContentFromAppBar() { if (PlatformDetector.isTV()) { - _tvBrowseRailKey.currentState?.requestFocus(); + _focusTvBrowseRailWhenReady(); return; } @@ -331,6 +332,25 @@ class _DiscoverScreenState extends State } } + void _focusTvBrowseRailWhenReady() { + if (!PlatformDetector.isTV()) return; + if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; + + _pendingTvBrowseRailFocus = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (_tvBrowseHubs.isEmpty) return; + final rail = _tvBrowseRailKey.currentState; + if (rail == null) return; + _pendingTvBrowseRailFocus = false; + rail.requestFocus(); + }); + } + + void _applyPendingTvBrowseRailFocus() { + if (_pendingTvBrowseRailFocus) _focusTvBrowseRailWhenReady(); + } + /// Handle vertical navigation between hubs /// Returns true if the navigation was handled bool _handleVerticalNavigation(int hubIndex, bool isUp) { @@ -601,6 +621,10 @@ class _DiscoverScreenState extends State @override void focusActiveTabIfReady() { + if (PlatformDetector.isTV()) { + _focusTvBrowseRailWhenReady(); + return; + } _focusTopBoundary(); } @@ -703,6 +727,7 @@ class _DiscoverScreenState extends State _continueWatchingHubKey ??= GlobalKey(); } }); + _applyPendingTvBrowseRailFocus(); // Focus hero section now that it's visible, but only if no modal route is on top if (!PlatformDetector.isTV() && onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) { @@ -759,6 +784,7 @@ class _DiscoverScreenState extends State _areHubsLoading = false; _updateHubKeys(); }); + _applyPendingTvBrowseRailFocus(); if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) { _initialLoadComplete = true; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 2eaebee3..33ea2fa5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -140,6 +140,7 @@ class _MediaDetailScreenState extends State // Inline season tabs int _selectedSeasonIndex = 0; final Map> _episodeCache = {}; + final Set _seasonEpisodeLoadsInFlight = {}; bool _isLoadingSeasonEpisodes = false; List _seasonTabFocusNodes = []; @@ -728,8 +729,7 @@ class _MediaDetailScreenState extends State if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes; if (_seasons.isEmpty) return true; if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false; - final selectedSeason = _seasons[_selectedSeasonIndex]; - return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(selectedSeason.id); + return !_isLoadingSeasonEpisodes && _seasons.every((season) => _episodeCache.containsKey(season.id)); } if (metadata.isSeason) { @@ -1434,8 +1434,12 @@ class _MediaDetailScreenState extends State if (shouldShowEpisodesDirectly) { await _fetchAllEpisodes(); } else if (seasonsWithServerId.isNotEmpty) { - // Fetch episodes for the auto-selected season - unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); + if (PlatformDetector.isTV()) { + await _fetchTvSeasonEpisodeCaches(onDeckSeasonIndex); + } else { + // Fetch episodes for the auto-selected season + unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); + } } } catch (e, st) { appLogger.w('Seasons load failed', error: e, stackTrace: st); @@ -1573,6 +1577,13 @@ class _MediaDetailScreenState extends State return; } + if (!_seasonEpisodeLoadsInFlight.add(seasonId)) { + setStateIfMounted(() { + if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true; + }); + return; + } + setStateIfMounted(() { if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true; }); @@ -1617,6 +1628,8 @@ class _MediaDetailScreenState extends State } } catch (e) { _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); + } finally { + _seasonEpisodeLoadsInFlight.remove(seasonId); } } @@ -1627,6 +1640,80 @@ class _MediaDetailScreenState extends State _seasons[seasonIndex].id == seasonId; } + Future _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async { + if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return; + + setStateIfMounted(() { + _isLoadingSeasonEpisodes = true; + }); + + final serverId = _metadata.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + if (serverId == null || client == null) { + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}); + return; + } + + final seasonsById = {for (final season in _seasons) season.id: season}; + final seasonsByIndex = { + for (final season in _seasons) + if (season.index != null) season.index!: season, + }; + final episodesBySeasonId = {for (final season in _seasons) season.id: []}; + + try { + var offset = 0; + var total = 0; + do { + final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); + if (!mounted) return; + if (page.items.isEmpty) break; + + final enriched = _enrichPlayableEpisodes(page.items, serverId); + for (final episode in enriched) { + final seasonId = _seasonIdForEpisode(episode, seasonsById: seasonsById, seasonsByIndex: seasonsByIndex); + if (seasonId == null) continue; + episodesBySeasonId[seasonId]?.add(episode); + } + + offset += page.items.length; + total = page.totalCount; + } while (offset < total && total > 0); + + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId); + } catch (e, st) { + appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st); + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}); + } + } + + String? _seasonIdForEpisode( + MediaItem episode, { + required Map seasonsById, + required Map seasonsByIndex, + }) { + final parentId = episode.parentId; + if (parentId != null && seasonsById.containsKey(parentId)) return parentId; + + final parentIndex = episode.parentIndex; + if (parentIndex != null) return seasonsByIndex[parentIndex]?.id; + + return null; + } + + void _completeTvSeasonEpisodeCaches(int selectedSeasonIndex, Map> episodesBySeasonId) { + setStateIfMounted(() { + for (final season in _seasons) { + _episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const []); + } + + if (selectedSeasonIndex >= 0 && selectedSeasonIndex < _seasons.length) { + _episodes = List.of(_episodeCache[_seasons[selectedSeasonIndex].id] ?? const []); + } + _isLoadingSeasonEpisodes = false; + }); + } + void _completeSeasonEpisodesLoad({ required int seasonIndex, required String seasonId, @@ -3187,7 +3274,9 @@ class _MediaDetailScreenState extends State if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { for (var i = 0; i < _seasons.length; i++) { final season = _seasons[i]; - final episodes = i == _selectedSeasonIndex ? _episodes : (_episodeCache[season.id] ?? const []); + final cachedEpisodes = _episodeCache[season.id]; + if (cachedEpisodes == null) continue; + final episodes = i == _selectedSeasonIndex ? _episodes : cachedEpisodes; hubs.add( MediaHub( id: '$_tvDetailSeasonHubIdPrefix$i', diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart new file mode 100644 index 00000000..98fcffec --- /dev/null +++ b/test/screens/discover_screen_test.dart @@ -0,0 +1,189 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/mixins/refreshable.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/companion_remote_provider.dart'; +import 'package:plezy/providers/hidden_libraries_provider.dart'; +import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/discover_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/watch_together/watch_together.dart'; +import 'package:plezy/widgets/tv_browse_rail.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + TvDetectionService.debugSetAppleTVOverride(true); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async { + await SettingsService.getInstance(); + + final item = MediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: 'server_1', + serverName: 'Server', + ); + final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1); + final client = _FakeMediaServerClient(hubs: [hub]); + 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 discoverKey = GlobalKey>(); + + addTearDown(() async { + 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), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(TvBrowseRail), findsOneWidget); + + tester.state(find.byType(FocusableActionBar)).requestFocusOnFirst(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + + (discoverKey.currentState! as FocusableTab).focusActiveTabIfReady(); + await tester.pump(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + }); +} + +class _FakeMediaServerClient implements MediaServerClient { + final List hubs; + + _FakeMediaServerClient({required this.hubs}); + + @override + String get serverId => 'server_1'; + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future> fetchContinueWatching({int? count = 20}) async => const []; + + @override + Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async => hubs; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeProfileRegistry extends ProfileRegistry { + _FakeProfileRegistry(super.db); + + @override + Stream> watchProfiles() => Stream.value(const []); + + @override + Future> list() async => const []; +} + +class _FakeConnectionRegistry extends ConnectionRegistry { + _FakeConnectionRegistry(super.db); + + @override + Stream> watchConnections() => Stream.value(const []); + + @override + Future> list() async => const []; +} + +class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry { + _FakeProfileConnectionRegistry(super.db); + + @override + Stream> watchAll() => Stream.value(const []); +} diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart new file mode 100644 index 00000000..005b5fb7 --- /dev/null +++ b/test/screens/media_detail_screen_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/media_detail_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + TvDetectionService.debugSetAppleTVOverride(true); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('TV detail reveals season hubs together after all episode caches load', (tester) async { + await SettingsService.getInstance(); + + final show = MediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + serverId: 'server_1', + serverName: 'Server', + ); + final season1 = MediaItem( + id: 'season_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + index: 1, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final season2 = MediaItem( + id: 'season_2', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 2', + index: 2, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode1 = MediaItem( + id: 'episode_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 1', + index: 1, + parentId: season1.id, + parentIndex: season1.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode2 = MediaItem( + id: 'episode_2', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 2', + index: 1, + parentId: season2.id, + parentIndex: season2.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + + final descendantsCompleter = Completer>(); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, season2], + }, + pendingPlayableDescendants: descendantsCompleter.future, + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Season 1'), findsNothing); + expect(find.text('Season 2'), findsNothing); + + descendantsCompleter.complete([episode1, episode2]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Season 1'), findsOneWidget); + expect(find.text('Season 2'), findsOneWidget); + }); +} + +class _FakeMediaServerClient implements MediaServerClient { + final MediaItem show; + final Map> childrenByParent; + final Future> pendingPlayableDescendants; + + _FakeMediaServerClient({ + required this.show, + required this.childrenByParent, + required this.pendingPlayableDescendants, + }); + + @override + String get serverId => 'server_1'; + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { + return (item: show, onDeckEpisode: null); + } + + @override + Future> fetchChildren(String parentId) async { + return childrenByParent[parentId] ?? const []; + } + + @override + Future> fetchPlayableDescendantsPage( + String parentId, { + int? start, + int? size, + AbortController? abort, + }) async { + final items = await pendingPlayableDescendants; + return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0); + } + + @override + Future> fetchRelatedHubs(String id, {int count = 10}) async => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}