fix(tv): play episodes from detail rails

close #1608
This commit is contained in:
edde746
2026-07-20 10:28:31 +02:00
parent 0283c3bfff
commit 5fb167898e
2 changed files with 147 additions and 1 deletions
+10 -1
View File
@@ -3969,7 +3969,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
bool _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) {
Future<bool> _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) {
+137
View File
@@ -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<void>.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<NavigatorState>();
final observer = _RecordingNavigatorObserver(popVideoPlayerImmediately: true);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<OfflineWatchSyncService>.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<TvBrowseRailState>(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<MediaItem?> 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<List<MediaItem>> fetchChildren(String parentId) async {
return childrenByParent[parentId] ?? const [];
@@ -1092,6 +1210,25 @@ class _FakeMediaServerClient implements MediaServerClient {
@override
Future<List<MediaHub>> 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 = <String?>[];
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
pushedRouteNames.add(route.settings.name);
if (popVideoPlayerImmediately && route.settings.name == kVideoPlayerRouteName) {
scheduleMicrotask(() => navigator?.pop());
}
}
}