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:
@@ -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