feat(player): keep the session's explicit track choices across episodes

Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
This commit is contained in:
edde746
2026-08-04 16:10:02 +02:00
parent 61ae314c94
commit 4872adcde3
13 changed files with 721 additions and 112 deletions
@@ -176,7 +176,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
final preset = options.qualityPreset;
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedAudioStreamId = options.selectedAudioStreamId == null
? options.preferredAudioTrack == null
? null
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
final int? maxStreamingBitrate = wantsOriginal
? null
@@ -42,6 +42,13 @@ class PlaybackInitializationOptions {
/// server pick".
final int? selectedAudioStreamId;
/// Semantic audio carry for negotiation when [selectedAudioStreamId] is
/// null: the id belongs to another item, but language, title, codec, and
/// channels let a backend resolve the equivalent stream on this one
/// (transcodes bake the audio choice in, so post-open switching cannot
/// recover it). Resolution failure falls back to the server's pick.
final AudioTrack? preferredAudioTrack;
/// Preferred subtitle carried across navigation/reloads. Backends that put
/// embedded subtitles in the rendition can use this during negotiation;
/// sidecar-capable backends keep subtitle delivery independent.
@@ -62,6 +69,7 @@ class PlaybackInitializationOptions {
this.qualityPreset = TranscodeQualityPreset.original,
this.audioQualityPreset,
this.selectedAudioStreamId,
this.preferredAudioTrack,
this.preferredSubtitleTrack,
this.sessionIdentifier,
this.transcodeSessionId,
+16 -11
View File
@@ -329,18 +329,23 @@ class PlaybackSubtitleResolver {
return choices[(normalizedCurrentIndex + advances) % choices.length];
}
/// Stable semantic descriptor for a source audio row — the audio twin of
/// [subtitleTrackForSource]. The row's own title comes first: server
/// display titles collapse to the bare language and cannot tell a
/// commentary or alternate mix from the main track on another item.
static AudioTrack audioTrackForSource(MediaAudioTrack track) {
return AudioTrack(
id: 'source:${track.id}',
title: track.title ?? track.displayTitle ?? track.language,
language: track.languageCode ?? track.language,
codec: track.codec,
channels: track.channels,
isDefault: track.selected,
);
}
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
return [
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
AudioTrack(
id: 'source:${track.id}',
title: track.displayTitle ?? track.title ?? track.language,
language: track.languageCode ?? track.language,
codec: track.codec,
channels: track.channels,
isDefault: track.selected,
),
];
return [for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[]) audioTrackForSource(track)];
}
}
+14 -4
View File
@@ -73,6 +73,7 @@ import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
import 'track_selection_service.dart';
part 'plex_client/parts/live_tv.dart';
part 'plex_client/parts/playlists.dart';
@@ -3252,6 +3253,10 @@ class PlexClient
if (!data.hasValidVideoUrl) {
throw PlaybackException(t.messages.fileInfoNotAvailable, reason: PlaybackFailureReason.noPlayableSource);
}
final carriedAudioTrack = options.selectedAudioStreamId == null ? options.preferredAudioTrack : null;
final carriedAudioStreamId = carriedAudioTrack == null || data.mediaInfo == null
? null
: findSourceAudioTrackForIntent(carriedAudioTrack, data.mediaInfo!.audioTracks)?.id;
// Tracks consult the music preset — [qualityPreset] is video-shaped
// (resolution/videoQuality) and is ignored for audio.
@@ -3286,7 +3291,9 @@ class PlexClient
return _transcodeFallbackResult(data, result.outcome, options);
}
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final resolvedAudioId = carriedAudioTrack == null
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
: carriedAudioStreamId;
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
@@ -3314,7 +3321,7 @@ class PlexClient
);
}
return _transcodeFallbackResult(data, result.outcome, options);
return _transcodeFallbackResult(data, result.outcome, options, activeAudioStreamId: carriedAudioStreamId);
}
return PlaybackInitializationResult(
@@ -3323,6 +3330,7 @@ class PlexClient
mediaInfo: data.mediaInfo,
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
activeAudioStreamId: carriedAudioStreamId,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
@@ -3340,8 +3348,9 @@ class PlexClient
PlaybackInitializationResult _transcodeFallbackResult(
PlexVideoPlaybackData data,
TranscodeDecisionOutcome outcome,
PlaybackInitializationOptions options,
) {
PlaybackInitializationOptions options, {
int? activeAudioStreamId,
}) {
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
? TranscodeFallbackReason.directPlayOnly
: TranscodeFallbackReason.decisionFailed;
@@ -3354,6 +3363,7 @@ class PlexClient
isOffline: false,
isTranscoding: false,
fallbackReason: fallbackReason,
activeAudioStreamId: activeAudioStreamId,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
+113 -46
View File
@@ -357,93 +357,147 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
/// signal is a title like "Swedish" are common, and declining them turned the
/// viewer's subtitles off on every episode advance. Codec/external parity is
/// never sufficient evidence on its own (an arbitrary untagged row would
/// reintroduce the #1716 wrong-track class); it only breaks ties WITHIN the
/// title-matched set, and a residual tie declines rather than guesses.
/// reintroduce the #1716 wrong-track class); technical parity only breaks
/// ties the stronger tiers left, and any tie still standing at the top
/// declines rather than guesses — in every band, so two indistinguishable
/// same-language rows never latch by catalog order.
MediaSubtitleTrack? findSourceTrackForIntent(SubtitleIntent intent, List<MediaSubtitleTrack> sourceTracks) {
return _findTrackForIntent(
intent,
return _findTrackByEvidenceBands(
sourceTracks,
intentLanguage: intent.language,
isSelectable: (_) => true,
classMatches: (row) => row.effectiveForced == intent.forced,
language: (row) => row.languageCode ?? row.language,
effectiveForced: (row) => row.effectiveForced,
titleScore: (row) => _titleScore(intent.title, row.title, row.displayTitle),
codec: (row) => row.codec,
isExternal: (row) => row.isExternal,
codecMatches: (row) => _subtitleCodecsMatch(intent.codec, row.codec),
extraScore: (row) => intent.isExternal == row.isExternal ? 1 : 0,
);
}
/// Native-track twin of [findSourceTrackForIntent], for catalogs the source
/// side cannot describe (legacy offline sidecars) and late-arriving tracks.
SubtitleTrack? findNativeTrackForIntent(SubtitleIntent intent, List<SubtitleTrack> tracks) {
return _findTrackForIntent(
intent,
return _findTrackByEvidenceBands(
tracks,
intentLanguage: intent.language,
isSelectable: (track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id,
classMatches: (track) => track.effectiveForced == intent.forced,
language: (track) => track.language,
effectiveForced: (track) => track.effectiveForced,
titleScore: (track) => _titleScore(intent.title, track.title, null),
codec: (track) => track.codec,
isExternal: (track) => track.isExternal,
codecMatches: (track) => _subtitleCodecsMatch(intent.codec, track.codec),
extraScore: (track) => intent.isExternal == track.isExternal ? 1 : 0,
);
}
/// Shared scorer behind [findSourceTrackForIntent]/[findNativeTrackForIntent].
/// Audio twin of [findSourceTrackForIntent]: the source-catalog row that
/// serves a cross-item audio carry. [carried] is a semantic vehicle — its id
/// belongs to another item; language, title, codec, and channels are the
/// signal. Audio has no forced-class gate; channel-count parity replaces the
/// external-parity tiebreaker.
MediaAudioTrack? findSourceAudioTrackForIntent(AudioTrack carried, List<MediaAudioTrack> rows) {
return _findTrackByEvidenceBands(
rows,
intentLanguage: carried.language,
isSelectable: (_) => true,
classMatches: (_) => true,
language: (row) => row.languageCode ?? row.language,
titleScore: (row) => _titleScore(carried.title, row.title, row.displayTitle),
codecMatches: (row) => _audioCodecsMatch(carried.codec, row.codec),
extraScore: (row) => carried.channels != null && carried.channels == row.channels ? 1 : 0,
);
}
/// Native twin of [findSourceAudioTrackForIntent].
AudioTrack? findNativeAudioTrackForIntent(AudioTrack carried, List<AudioTrack> tracks) {
return _findTrackByEvidenceBands(
tracks,
intentLanguage: carried.language,
isSelectable: (track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id,
classMatches: (_) => true,
language: (track) => track.language,
titleScore: (track) => _titleScore(carried.title, track.title, null),
codecMatches: (track) => _audioCodecsMatch(carried.codec, track.codec),
extraScore: (track) => carried.channels != null && carried.channels == track.channels ? 1 : 0,
);
}
/// Sentinel id for a cross-item audio carry. Native player ids ('1', '2', …)
/// and Jellyfin `source:<Index>` ids are reused per item, so a carried track
/// with its original id can identity-match a DIFFERENT track that happens to
/// sit at the same position on the next episode — bypassing the evidence
/// bands and their ambiguity decline.
const String carriedAudioTrackId = 'carried';
/// Strips item-bound identity from an audio carry so only its semantics may
/// speak — the audio twin of [SubtitlePreference.demoteToIntent]. Same-item
/// reloads keep the original track and its identity fast path.
AudioTrack itemAgnosticAudioCarry(AudioTrack track) => track.copyWith(id: carriedAudioTrackId);
/// Shared evidence matcher behind the cross-item intent matchers.
///
/// Score bands keep the evidence classes strictly ordered: a language-parity
/// match starts at 10 while a title-evidence match tops out at 9
/// (codec 5 + title 3 + external 1), so the two can never tie and language
/// always outranks a title coincidence.
T? _findTrackForIntent<T extends Object>(
SubtitleIntent intent,
/// Candidates are compared lexicographically, strongest evidence first:
/// declared-language parity, then the semantic title/role match, then
/// technical parity (codec, then channels/external). Each tier only breaks
/// ties left by the tiers above it — a codec that changed between episodes
/// can never outvote the title that names the viewer's track, and language
/// parity always outranks a title coincidence. A tie left standing at the
/// top means the catalog cannot say which row the viewer meant: the match
/// declines and the ladder falls to the server's own per-item choice.
T? _findTrackByEvidenceBands<T extends Object>(
List<T> candidates, {
required String? intentLanguage,
required bool Function(T) isSelectable,
required bool Function(T) classMatches,
required String? Function(T) language,
required bool Function(T) effectiveForced,
required int Function(T) titleScore,
required String? Function(T) codec,
required bool Function(T) isExternal,
required bool Function(T) codecMatches,
required int Function(T) extraScore,
}) {
T? bestMatch;
var bestScore = -1;
var bestIsTitleEvidence = false;
List<int>? bestKey;
var bestIsAmbiguous = false;
for (final candidate in candidates) {
if (!isSelectable(candidate)) continue;
if (effectiveForced(candidate) != intent.forced) continue;
if (!classMatches(candidate)) continue;
final candidateLanguage = language(candidate);
final candidateTitleScore = titleScore(candidate);
final hasLanguageParity = intent.language != null && candidateLanguage != null;
var score = 0;
final hasLanguageParity = intentLanguage != null && candidateLanguage != null;
if (hasLanguageParity) {
// A declared language on both sides stays authoritative: a
// contradiction declines no matter what the title says.
if (!_languagesMatch(intent.language, candidateLanguage)) continue;
score += 10;
if (!_languagesMatch(intentLanguage, candidateLanguage)) continue;
} else if (candidateTitleScore < 3) {
// Language evidence is missing on at least one side; only a real
// title match may serve the intent then.
continue;
}
if (_subtitleCodecsMatch(intent.codec, codec(candidate))) score += 5;
score += candidateTitleScore;
if (intent.isExternal == isExternal(candidate)) score += 1;
if (score > bestScore) {
bestScore = score;
final key = [
hasLanguageParity ? 1 : 0,
candidateTitleScore,
codecMatches(candidate) ? 1 : 0,
extraScore(candidate),
];
final comparison = bestKey == null ? 1 : _compareEvidenceKeys(key, bestKey);
if (comparison > 0) {
bestKey = key;
bestMatch = candidate;
bestIsTitleEvidence = !hasLanguageParity;
bestIsAmbiguous = false;
} else if (score == bestScore && bestIsTitleEvidence) {
// Only title-evidence matches can tie (their band never reaches a
// language-parity score). Two rows the tiebreakers cannot separate
// mean the catalog cannot say which one the viewer meant.
} else if (comparison == 0) {
bestIsAmbiguous = true;
}
}
if (bestIsTitleEvidence && bestIsAmbiguous) return null;
if (bestIsAmbiguous) return null;
return bestMatch;
}
int _compareEvidenceKeys(List<int> a, List<int> b) {
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return a[i].compareTo(b[i]);
}
return 0;
}
/// Find the MPV audio track that matches a Plex audio track
AudioTrack? findMpvTrackForPlexAudio(
MediaAudioTrack plexTrack,
@@ -716,10 +770,6 @@ class TrackSelectionService {
return null;
}
AudioTrack? findBestAudioMatch(List<AudioTrack> availableTracks, AudioTrack preferred) {
return findBestTrackMatch<AudioTrack>(availableTracks, preferred, (t) => t.id, (t) => t.title, (t) => t.language);
}
AudioTrack? findAudioTrackByProfile(List<AudioTrack> availableTracks, MediaServerUserProfile profile) {
if (availableTracks.isEmpty || !profile.autoSelectAudio) return null;
@@ -947,12 +997,29 @@ class TrackSelectionService {
AudioTrack? trackToSelect;
// Priority 1: Try to match preferred track from navigation
// Priority 1: the carried preference. Full identity first (a same-item
// reload passing back the identical track), then the cross-item evidence
// bands — bridged language parity or a unique title match with
// codec/channel tiebreaks. This replaces the old first-match language
// tier, which latched onto an arbitrary same-language (or same-untagged)
// row and lost the viewer's commentary/dub distinction across episodes.
if (preferredAudioTrack != null) {
trackToSelect = findBestAudioMatch(availableTracks, preferredAudioTrack);
trackToSelect =
availableTracks
.where(
(track) =>
track.id != AudioTrack.auto.id &&
track.id != AudioTrack.off.id &&
track.id == preferredAudioTrack.id &&
track.title == preferredAudioTrack.title &&
track.language == preferredAudioTrack.language,
)
.firstOrNull ??
findNativeAudioTrackForIntent(preferredAudioTrack, availableTracks);
if (trackToSelect != null) {
return TrackSelectionResult(trackToSelect, TrackSelectionPriority.navigation);
}
appLogger.d('Audio carry declined: ${preferredAudioTrack.language}/${preferredAudioTrack.title}');
}
// Priority 2: Check server-selected track from media info