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;