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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user