diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 20aef26a..f62a41a6 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -106,7 +106,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { /// Navigates to a new episode by reusing the current player whenever possible. Future _navigateToEpisode(MediaItem episodeMetadata) async { - if (player == null) { + final currentPlayer = player; + if (currentPlayer == null) { if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata)); return; } @@ -118,6 +119,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _effectiveSelectedMediaIndex >= 0 && _effectiveSelectedMediaIndex < _availableVersions.length ? _availableVersions[_effectiveSelectedMediaIndex].signature : null; + final committedSubtitleSelection = _playbackSession?.subtitleSelection; + final primarySubtitlePreference = subtitlePreferenceForItemChange( + hasCommittedSelection: committedSubtitleSelection != null, + committedTrack: committedSubtitleSelection?.primaryTrack, + nativeTrack: currentPlayer.state.track.subtitle, + ); + final secondarySubtitlePreference = subtitlePreferenceForItemChange( + hasCommittedSelection: committedSubtitleSelection != null, + committedTrack: committedSubtitleSelection?.secondaryTrack, + nativeTrack: currentPlayer.state.track.secondarySubtitle, + ); await _reloadMediaInPlace( metadata: episodeMetadata, selectedMediaIndex: _effectiveSelectedMediaIndex, @@ -128,6 +140,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // meaningless on the new item, so let preferences pick the track. useCurrentAudioStreamSelection: false, preserveCurrentTrackSelection: true, + preservedSubtitleTrack: primarySubtitlePreference, + preservedSecondarySubtitleTrack: secondarySubtitlePreference, reason: 'episode navigation', ); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0b9c170a..9fcea3ee 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -134,6 +134,48 @@ bool shouldAutoStartReloadedMedia({ required bool startPaused, }) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused; +/// Builds an item-agnostic subtitle preference for an episode replacement. +/// +/// Source ids and sidecar URIs belong to the current media item. Only the +/// committed semantic choice may cross the item boundary; native state is a +/// fallback for sessions created before source-backed selection was recorded. +SubtitleTrack? subtitlePreferenceForItemChange({ + required bool hasCommittedSelection, + required SubtitleTrack? committedTrack, + required SubtitleTrack? nativeTrack, +}) { + SubtitleTrack? normalize(SubtitleTrack? track, {required bool preserveOff}) { + if (track == null) return null; + if (track.id == SubtitleTrack.off.id) return preserveOff ? SubtitleTrack.off : null; + + final hasSemanticMetadata = + (track.title?.isNotEmpty ?? false) || + (track.language?.isNotEmpty ?? false) || + (track.codec?.isNotEmpty ?? false); + if (!hasSemanticMetadata) return null; + + return SubtitleTrack( + id: 'navigation', + title: track.title, + language: track.language, + codec: track.codec, + isDefault: track.isDefault, + isForced: track.isForced, + isExternal: track.isExternal, + ); + } + + if (!hasCommittedSelection) { + return normalize(nativeTrack, preserveOff: true); + } + + if (committedTrack == null) return SubtitleTrack.off; + + final committedPreference = normalize(committedTrack, preserveOff: true); + if (committedPreference != null) return committedPreference; + return normalize(nativeTrack, preserveOff: false); +} + /// The in-place media-source transitions a [VideoPlayerScreenState] can run. /// They are mutually exclusive by construction — entry points bail while a /// transition is in flight. diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index db1a53c3..9353ad80 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -62,6 +62,7 @@ import 'jellyfin_playback_urls.dart'; import 'jellyfin_trickplay_service.dart'; import 'playback_initialization_types.dart'; import 'scrub_preview_source.dart'; +import 'track_selection_service.dart'; import '../mpv/mpv.dart'; part 'jellyfin_client/parts/browse.dart'; diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 75ed62a8..eef776fc 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -329,11 +329,25 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) { if (preferred == null) return null; if (preferred.id == SubtitleTrack.off.id) return -1; + const sourcePrefix = 'source:'; - if (!preferred.id.startsWith(sourcePrefix)) return null; - final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length)); - if (explicit == null) return null; - return mediaInfo.subtitleTracks.any((track) => track.id == explicit) ? explicit : null; + if (preferred.id.startsWith(sourcePrefix)) { + final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length)); + if (explicit != null) { + final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull; + if (exactSource != null) { + // A source id is authoritative only within one item. When semantic + // metadata is available, reject a coincidentally reused episode id. + final exactMatch = findPlexTrackForMpvSubtitle(preferred, [exactSource]); + final hasLanguage = preferred.language?.isNotEmpty ?? false; + if (!hasLanguage || (exactMatch != null && preferred.isForced == exactSource.forced)) { + return explicit; + } + } + } + } + + return findPlexTrackForMpvSubtitle(preferred, mediaInfo.subtitleTracks)?.id; } Map? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) { diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 9a2b6613..c38b1f95 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -76,6 +76,25 @@ class PlaybackSubtitleSelection { class PlaybackSubtitleResolver { const PlaybackSubtitleResolver._(); + static SubtitleTrack? _sourceBackedPreference( + SubtitleTrack? preferred, + MediaSourceInfo? mediaInfo, + List<_SubtitleCandidate> candidates, + ) { + if (preferred == null || preferred.id == SubtitleTrack.off.id) return preferred; + + final sourceMatch = findPlexTrackForMpvSubtitle( + preferred, + mediaInfo?.subtitleTracks ?? const [], + ); + if (sourceMatch == null) return preferred; + + for (final candidate in candidates) { + if (candidate.sourceStreamId == sourceMatch.id) return candidate.track; + } + return preferred; + } + static PlaybackSubtitleSelection resolve({ required MediaItem metadata, required MediaSourceInfo? mediaInfo, @@ -116,7 +135,8 @@ class PlaybackSubtitleResolver { plexMediaInfo: mediaInfo, ); final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track; - final primaryResult = service.selectSubtitleTrack(availableTracks, preferredSubtitleTrack, selectedAudio); + final primaryPreference = _sourceBackedPreference(preferredSubtitleTrack, mediaInfo, candidates); + final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio); final primary = primaryResult.track; if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off(); @@ -124,8 +144,9 @@ class PlaybackSubtitleResolver { if (primaryCandidate == null) return const PlaybackSubtitleSelection.off(); _SubtitleCandidate? secondaryCandidate; - if (preferredSecondarySubtitleTrack != null && preferredSecondarySubtitleTrack.id != SubtitleTrack.off.id) { - final secondary = service.findBestSubtitleMatch(availableTracks, preferredSecondarySubtitleTrack); + final secondaryPreference = _sourceBackedPreference(preferredSecondarySubtitleTrack, mediaInfo, candidates); + if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) { + final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference); secondaryCandidate = candidates .where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id) .firstOrNull; diff --git a/test/screens/video_player/player_initialization_lifecycle_test.dart b/test/screens/video_player/player_initialization_lifecycle_test.dart index e65a6337..a6969144 100644 --- a/test/screens/video_player/player_initialization_lifecycle_test.dart +++ b/test/screens/video_player/player_initialization_lifecycle_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_button.dart'; import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/screens/video_player_screen.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:provider/provider.dart'; @@ -41,6 +42,69 @@ void main() { ); }); + test('item-change subtitle preference carries committed semantics without item identity', () { + const committed = SubtitleTrack( + id: 'source:4', + title: 'French - SRT', + language: 'fra', + codec: 'srt', + isForced: true, + isExternal: true, + uri: 'https://example.test/old-episode/subtitle.srt', + ); + + final result = subtitlePreferenceForItemChange( + hasCommittedSelection: true, + committedTrack: committed, + nativeTrack: SubtitleTrack.off, + ); + + expect(result, isNotNull); + expect(result!.id, 'navigation'); + expect(result.title, committed.title); + expect(result.language, committed.language); + expect(result.codec, committed.codec); + expect(result.isForced, isTrue); + expect(result.isExternal, isTrue); + expect(result.uri, isNull); + }); + + test('item-change subtitle preference preserves committed off and empty secondary slots', () { + expect( + subtitlePreferenceForItemChange( + hasCommittedSelection: true, + committedTrack: SubtitleTrack.off, + nativeTrack: const SubtitleTrack(id: '7', language: 'eng'), + ), + same(SubtitleTrack.off), + ); + expect( + subtitlePreferenceForItemChange( + hasCommittedSelection: true, + committedTrack: null, + nativeTrack: const SubtitleTrack(id: '8', language: 'swe'), + ), + same(SubtitleTrack.off), + ); + }); + + test('item-change subtitle preference uses native metadata only without a committed selection', () { + final result = subtitlePreferenceForItemChange( + hasCommittedSelection: false, + committedTrack: null, + nativeTrack: const SubtitleTrack( + id: '9', + title: 'English', + language: 'eng', + uri: 'https://example.test/old-episode/native.srt', + ), + ); + + expect(result?.id, 'navigation'); + expect(result?.language, 'eng'); + expect(result?.uri, isNull); + }); + testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async { final failedDispose = Completer(); final replacementInitialize = Completer(); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b5e7491e..3713b6e0 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -924,7 +924,7 @@ void main() { expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); - test('getPlaybackInitialization negotiates the requested DirectStream subtitle and exposes delivery', () async { + test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( @@ -944,6 +944,7 @@ void main() { {'Index': 0, 'Type': 'Video'}, {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, + {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, ], }, ], @@ -984,6 +985,16 @@ void main() { 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', }, + { + 'Index': 5, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English Forced - SRT', + 'IsForced': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', + }, ], }, ], @@ -997,34 +1008,64 @@ void main() { ); addTearDown(scoped.close); - final result = await scoped.getPlaybackInitialization( - PlaybackInitializationOptions( - metadata: testMediaItem( - id: 'item-1', - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - serverId: 'srv-1', - ), - selectedMediaIndex: 0, - preferredSubtitleTrack: const SubtitleTrack( - id: 'source:4', - title: 'French - SRT', - language: 'fra', - codec: 'srt', + Future initialize(SubtitleTrack preference) { + return scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + preferredSubtitleTrack: preference, ), + ); + } + + void expectRequestedSubtitleIndex(int? expected) { + expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString()); + final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map; + expect(playbackInfoJson['SubtitleStreamIndex'], expected); + } + + final result = await initialize( + const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'), + ); + expectRequestedSubtitleIndex(4); + + await initialize(const SubtitleTrack(id: 'navigation', title: 'English - SRT', language: 'eng', codec: 'srt')); + expectRequestedSubtitleIndex(3); + + await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt')); + expectRequestedSubtitleIndex(4); + + await initialize( + const SubtitleTrack( + id: 'source:3', + title: 'English Forced - SRT', + language: 'eng', + codec: 'srt', + isForced: true, ), ); + expectRequestedSubtitleIndex(5); + + await initialize(SubtitleTrack.off); + expectRequestedSubtitleIndex(-1); + + await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt')); + expectRequestedSubtitleIndex(null); - expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], '4'); - final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map; - expect(playbackInfoJson['SubtitleStreamIndex'], 4); expect(result.playMethod, 'DirectStream'); - expect(result.mediaInfo!.subtitleTracks, hasLength(2)); + expect(result.mediaInfo!.subtitleTracks, hasLength(3)); expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue); - expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4]); - expect(result.externalSubtitles, hasLength(2)); - expect(Uri.parse(result.externalSubtitles.first.uri!).path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt'); - expect(Uri.parse(result.externalSubtitles.last.uri!).path, '/Videos/item-1/src-1/Subtitles/4/Stream.srt'); + expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]); + expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [ + '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + '/Videos/item-1/src-1/Subtitles/4/Stream.srt', + '/Videos/item-1/src-1/Subtitles/5/Stream.srt', + ]); }); test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async { diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index bf36255b..d182810e 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart'; MediaSubtitleTrack _sourceSubtitle( int id, { String language = 'eng', + bool forced = false, bool selected = false, bool external = false, bool usesExternalDelivery = false, @@ -21,7 +22,7 @@ MediaSubtitleTrack _sourceSubtitle( languageCode: language, title: 'Subtitle $id', selected: selected, - forced: false, + forced: forced, external: external, usesExternalDelivery: usesExternalDelivery, ); @@ -178,6 +179,54 @@ void main() { expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt'); }); + test('item-change semantic preference selects the matching new sidecar', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([ + _sourceSubtitle(7, language: 'eng', usesExternalDelivery: true), + _sourceSubtitle(9, language: 'fra', usesExternalDelivery: true), + ]), + sidecars: [ + _sidecar(7), + _sidecar(9, language: 'fra'), + ], + preferredSubtitleTrack: const SubtitleTrack( + id: 'navigation', + title: 'English from the previous episode', + language: 'eng', + codec: 'srt', + isExternal: true, + ), + ); + + expect(result.primarySourceStreamId, 7); + expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/7.srt'); + expect(result.sidecarsAtOpen, hasLength(1)); + }); + + test('item-change semantic preference distinguishes forced and full subtitles in one language', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([ + _sourceSubtitle(7, language: 'eng', usesExternalDelivery: true), + _sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true), + ]), + sidecars: [_sidecar(7), _sidecar(8)], + preferredSubtitleTrack: const SubtitleTrack( + id: 'navigation', + title: 'English forced from the previous episode', + language: 'eng', + codec: 'srt', + isForced: true, + isExternal: true, + ), + ); + + expect(result.primarySourceStreamId, 8); + expect(result.primaryTrack.isForced, isTrue); + expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt'); + }); + test('selected embedded subtitle keeps sidecars out of the open', () { final result = PlaybackSubtitleResolver.resolve( metadata: metadata,