fix(player): preserve the forced-subtitle class across episode boundaries

Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.

Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.

Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.

Ref #1716
This commit is contained in:
edde746
2026-07-30 02:46:10 +02:00
parent f13f5af6e2
commit daab4f1e24
22 changed files with 921 additions and 231 deletions
@@ -14,7 +14,7 @@ Future<bool> deferTranscodeSubtitleSelection({
required bool Function() shouldContinue,
}) async {
final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar);
trackManager.preferredSubtitleTrack = deferredTrack;
trackManager.preferredSubtitleTrack = SubtitlePreference.track(deferredTrack);
// Persist first: the screen callback routes to onSubtitleTrackSelectedByUser,
// which invalidates the pending selection. Arming before that would retire the
// deferred pass we depend on to apply this choice once mpv discovers the sidecar.
@@ -248,10 +248,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls());
final preferredSubtitleTrackForReload = newSubtitleChoice == null
? _playbackSession?.subtitleSelection.primaryTrack
? SubtitlePreference.trackOrNull(_playbackSession?.subtitleSelection.primaryTrack)
: newSubtitleChoice.isOff
? SubtitleTrack.off
: PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!);
? const SubtitlePreference.off()
: SubtitlePreference.trackOrNull(
PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!),
);
final effectiveMediaSourceId = newMediaIndex != null
? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId
: _requestedMediaSourceId;
@@ -417,9 +419,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Duration? resumePosition,
bool preserveCurrentTrackSelection = false,
AudioTrack? preservedAudioTrack,
SubtitleTrack? preservedSubtitleTrack,
SubtitleTrack? preservedSecondarySubtitleTrack,
SubtitleTrack? preferredSubtitleTrackOverride,
SubtitlePreference? preservedSubtitleTrack,
SubtitlePreference? preservedSecondarySubtitleTrack,
SubtitlePreference? preferredSubtitleTrackOverride,
bool startPaused = false,
bool useCurrentAudioStreamSelection = true,
bool showErrorUi = true,
@@ -473,9 +475,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
: null;
final currentSubtitleTrack =
preferredSubtitleTrackOverride ??
(preserveCurrentTrackSelection ? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle : null);
(preserveCurrentTrackSelection
? preservedSubtitleTrack ?? SubtitlePreference.trackOrNull(currentPlayer.state.track.subtitle)
: null);
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle
? preservedSecondarySubtitleTrack ??
SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle)
: null;
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
var didOpenReplacement = false;
@@ -545,7 +550,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
(selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId);
final initializationSubtitleTrack = preservesRequestedSubtitleSource
? currentSubtitleTrack
: PlaybackSubtitleResolver.preferenceWithoutSourceIdentity(currentSubtitleTrack);
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
try {
// Eager identity-only: the loading UI shows the new title immediately,
// while the selection/source state flips with the session commit at
@@ -757,8 +762,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
plexClient: plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: subtitleSelection.primaryTrack,
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
);
_trackManager = trackManager;
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
@@ -319,8 +319,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
resumePosition: resumePosition,
preserveCurrentTrackSelection: true,
preservedAudioTrack: audioTrack,
preservedSubtitleTrack: subtitleTrack,
preservedSecondarySubtitleTrack: secondarySubtitleTrack,
preservedSubtitleTrack: SubtitlePreference.trackOrNull(subtitleTrack),
preservedSecondarySubtitleTrack: SubtitlePreference.trackOrNull(secondarySubtitleTrack),
startPaused: true,
reason: 'TV background suspend restore',
);
@@ -83,8 +83,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
required MediaItem metadata,
required PlaybackInitializationResult result,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool preserveSubtitleSourceIdentity = true,
}) async {
await _waitForProfileSettingsIfNeeded();
@@ -440,8 +440,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
required PlexClient? plexClient,
required MediaServerUserProfile? Function() getProfileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
}) {
return TrackManager(
player: forPlayer,
@@ -346,8 +346,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: subtitleSelection.primaryTrack,
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
);
// Store only the active sidecars for re-use after backend fallback.
+15 -29
View File
@@ -62,6 +62,7 @@ import '../services/display_mode_service.dart';
import '../services/media_control_router.dart';
import '../services/settings_service.dart';
import '../services/sleep_timer_service.dart';
import '../services/subtitle_preference.dart';
import '../services/track_manager.dart';
import '../services/track_selection_service.dart';
import '../services/ambient_lighting_service.dart';
@@ -137,39 +138,27 @@ bool shouldAutoStartReloadedMedia({
/// 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({
/// committed semantic choice — a [SubtitleIntent] — may cross the item
/// boundary; native state is a fallback for sessions created before
/// source-backed selection was recorded.
SubtitlePreference? subtitlePreferenceForItemChange({
required bool hasCommittedSelection,
required SubtitleTrack? committedTrack,
required SubtitleTrack? nativeTrack,
}) {
SubtitleTrack? normalize(SubtitleTrack? track, {required bool preserveOff}) {
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
if (track == null) return null;
if (track.id == SubtitleTrack.off.id) return preserveOff ? SubtitleTrack.off : null;
if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.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,
);
final intent = SubtitleIntent.fromTrack(track);
return intent == null ? null : SubtitlePreference.intent(intent);
}
if (!hasCommittedSelection) {
return normalize(nativeTrack, preserveOff: true);
}
if (committedTrack == null) return SubtitleTrack.off;
if (committedTrack == null) return const SubtitlePreference.off();
final committedPreference = normalize(committedTrack, preserveOff: true);
if (committedPreference != null) return committedPreference;
@@ -377,8 +366,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
late TranscodeQualityPreset _selectedQualityPreset;
int? _selectedAudioStreamId;
AudioTrack? _preferredAudioTrack;
SubtitleTrack? _preferredSubtitleTrack;
SubtitleTrack? _preferredSecondarySubtitleTrack;
SubtitlePreference? _preferredSubtitleTrack;
SubtitlePreference? _preferredSecondarySubtitleTrack;
bool _serverSupportsTranscoding = false;
// Kicked off early in the player initialization attempt for online non-live playback so
// the metadata fetch (and transcode-decision HTTP, if non-original preset)
@@ -788,8 +777,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playbackTranscodeSessionId = generateSessionIdentifier();
_selectedAudioStreamId = widget.selectedAudioStreamId;
_preferredAudioTrack = widget.preferredAudioTrack;
_preferredSubtitleTrack = widget.preferredSubtitleTrack;
_preferredSecondarySubtitleTrack = widget.preferredSecondarySubtitleTrack;
_preferredSubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSubtitleTrack);
_preferredSecondarySubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSecondarySubtitleTrack);
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
@@ -811,10 +800,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
);
}
if (_preferredSubtitleTrack != null) {
final subtitleDesc = _preferredSubtitleTrack!.id == "no"
? "OFF"
: "${_preferredSubtitleTrack!.title ?? _preferredSubtitleTrack!.id} (${_preferredSubtitleTrack!.language ?? "unknown"})";
appLogger.d('Preferred subtitle track: $subtitleDesc');
appLogger.d('Preferred subtitle track: $_preferredSubtitleTrack');
}
try {