fix(player): preserve subtitles across episode changes

close #1635
This commit is contained in:
edde746
2026-07-24 21:40:22 +02:00
parent db7bc17c49
commit c7b9eec087
8 changed files with 278 additions and 32 deletions
@@ -106,7 +106,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
/// Navigates to a new episode by reusing the current player whenever possible.
Future<void> _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',
);
}
+42
View File
@@ -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.
+1
View File
@@ -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';
@@ -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<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
+24 -3
View File
@@ -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 <MediaSubtitleTrack>[],
);
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;
@@ -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<void>();
final replacementInitialize = Completer<bool>();
+64 -23
View File
@@ -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<PlaybackInitializationResult> 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<String, dynamic>;
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<String, dynamic>;
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 {
@@ -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,