From 5fb167898e2a48e90ce8270f4ee2a665ecc3593d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:29:59 +0200 Subject: [PATCH] fix(tv): play episodes from detail rails close #1608 --- lib/screens/media_detail_screen.dart | 11 +- test/screens/media_detail_screen_test.dart | 137 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 7ccfebba..00b9e63f 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3969,7 +3969,16 @@ class _MediaDetailScreenState extends State ); } - bool _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) { + Future _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) async { + if (_isTvDetailEpisodeHub(hub) && item.isEpisode) { + await navigateToVideoPlayerWithRefresh( + context, + metadata: item, + isOffline: widget.isOffline, + onRefresh: () => unawaited(_refreshItemInPlace(item)), + ); + return true; + } if (hub.id != _tvDetailActorsHubId) return false; final personId = item.raw?[_tvDetailActorPersonIdRawKey]; if (personId is String && personId.isNotEmpty) { diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 5a9fdc2c..e11dc14e 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -29,6 +29,7 @@ import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -36,6 +37,7 @@ import 'package:plezy/utils/layout_constants.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/utils/video_player_navigation.dart'; import 'package:plezy/widgets/collapsible_text.dart'; import 'package:plezy/widgets/episode_card.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; @@ -657,6 +659,111 @@ void main() { expect(find.text('Episode 2'), findsOneWidget); }); + testWidgets('TV detail episode activation bypasses the open-details preference', (tester) async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.episodeAction, EpisodeAction.details); + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final show = testMediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + serverId: 'server_1', + serverName: 'Server', + ); + final season = testMediaItem( + id: 'season_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + index: 1, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode = testMediaItem( + id: 'episode_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 1', + index: 1, + parentId: season.id, + parentIndex: season.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season], + season.id: [episode], + }, + ); + final database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + JellyfinApiCache.initialize(database); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final offlineWatch = OfflineWatchSyncService(database: database, serverManager: manager); + final downloadManager = DownloadManagerService( + database: database, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + )..recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database); + await downloadProvider.ensureInitialized(); + addTearDown(() async { + downloadProvider.dispose(); + downloadManager.dispose(); + offlineWatch.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await database.close(); + }); + + final navigatorKey = GlobalKey(); + final observer = _RecordingNavigatorObserver(popVideoPlayerImmediately: true); + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: offlineWatch), + ], + child: MaterialApp( + navigatorKey: navigatorKey, + navigatorObservers: [observer], + theme: monoTheme(dark: true), + home: withProfileNavigationScope( + child: 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('Episode 1'), findsOneWidget); + observer.pushedRouteNames.clear(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(observer.pushedRouteNames, contains(kVideoPlayerRouteName)); + }); + group('watch state freshness (phone layout)', () { MediaItem buildShow({String? summary}) => testMediaItem( id: 'show_1', @@ -1058,6 +1165,17 @@ class _FakeMediaServerClient implements MediaServerClient { return (item: show, onDeckEpisode: null); } + @override + Future fetchItem(String id) async { + if (show.id == id) return show; + for (final items in childrenByParent.values) { + for (final item in items) { + if (item.id == id) return item; + } + } + return null; + } + @override Future> fetchChildren(String parentId) async { return childrenByParent[parentId] ?? const []; @@ -1092,6 +1210,25 @@ class _FakeMediaServerClient implements MediaServerClient { @override Future> fetchRelatedHubs(String id, {int count = 10}) async => const []; + @override + void close() {} + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _RecordingNavigatorObserver extends NavigatorObserver { + _RecordingNavigatorObserver({this.popVideoPlayerImmediately = false}); + + final bool popVideoPlayerImmediately; + final pushedRouteNames = []; + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + pushedRouteNames.add(route.settings.name); + if (popVideoPlayerImmediately && route.settings.name == kVideoPlayerRouteName) { + scheduleMicrotask(() => navigator?.pop()); + } + } +}