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
+67 -4
View File
@@ -153,6 +153,10 @@ bool playerChromeStartsVisible({required bool isTv}) => !isTv;
/// boundary; native state is a fallback for sessions created before
/// source-backed selection was recorded.
///
/// [sessionPreference] is the screen's last explicit viewer choice and wins
/// outright: automatic outcomes never overwrite it, so a catalog-gap episode
/// cannot reset the choice for the rest of the session.
///
/// [declinedPreference] is the committed selection's unserved carry: its off
/// is fallout from a metadata mismatch, not a viewer choice, so it must not
/// harden into an explicit off on the next item (#1785).
@@ -161,7 +165,10 @@ SubtitlePreference? subtitlePreferenceForItemChange({
required SubtitleTrack? committedTrack,
required SubtitleTrack? nativeTrack,
SubtitlePreference? declinedPreference,
SubtitlePreference? sessionPreference,
}) {
final sessionIntent = SubtitlePreference.demoteToIntent(sessionPreference);
if (sessionIntent != null) return sessionIntent;
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
if (track == null) return null;
if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.off() : null;
@@ -223,6 +230,26 @@ PlaybackSubtitleSelection subtitleSelectionForUserPick({
);
}
/// Session-preference form of a source-catalog subtitle choice that had to
/// go through a reload instead of a local track switch.
///
/// The authoritative row — not the reload's outcome — becomes the session
/// preference: a failed resolution must not turn the viewer's pick into a
/// carried off. Returns null when the row is absent from [rows] so a stale
/// id never overwrites the existing session preference.
SubtitlePreference? sessionPreferenceForSourceSubtitleChoice(
PlaybackSourceSubtitleChoice choice,
List<MediaSubtitleTrack> rows,
) {
if (choice.isOff) return const SubtitlePreference.off();
for (final row in rows) {
if (row.id == choice.sourceStreamId) {
return SubtitlePreference.track(PlaybackSubtitleResolver.subtitleTrackForSource(row));
}
}
return null;
}
/// 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.
@@ -425,6 +452,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
AudioTrack? _preferredAudioTrack;
SubtitlePreference? _preferredSubtitleTrack;
SubtitlePreference? _preferredSecondarySubtitleTrack;
/// Last explicit track choices made on this screen. They survive in-place
/// reloads but not route replacement. Automatic selections never overwrite
/// them, so an episode with a catalog gap cannot reset the viewer's choice
/// for the rest of the session (#1785).
AudioTrack? _sessionAudioPreference;
SubtitlePreference? _sessionSubtitlePreference;
SubtitlePreference? _sessionSecondarySubtitlePreference;
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)
@@ -1148,6 +1183,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
preferredVersionSignature: widget.preferredVersionSignature,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
@@ -1894,7 +1930,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackSelectedByUser(track);
Future<void> _onAudioTrackChanged(AudioTrack track) async {
if (track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id) {
_sessionAudioPreference = track;
}
await _trackManager?.onAudioTrackSelectedByUser(track);
}
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
@@ -1919,10 +1960,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
required _SubtitleSelectionSlot slot,
int? sourceStreamId,
}) {
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
if (track.id == SubtitleTrack.off.id) {
switch (slot) {
case _SubtitleSelectionSlot.primary:
_sessionSubtitlePreference = const SubtitlePreference.off();
case _SubtitleSelectionSlot.secondary:
_sessionSecondarySubtitlePreference = const SubtitlePreference.off();
}
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
// Dropping only the secondary must not relabel the primary's
@@ -1938,6 +1985,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return;
}
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
final info = _currentMediaInfo;
final currentPlayer = player;
@@ -1989,6 +2040,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
sourceTrack: sourceTrack,
sidecar: sidecar,
);
final resolvedTrack = switch (slot) {
_SubtitleSelectionSlot.primary => selection.primaryTrack,
_SubtitleSelectionSlot.secondary => selection.secondaryTrack,
};
if (resolvedTrack != null) {
switch (slot) {
case _SubtitleSelectionSlot.primary:
_sessionSubtitlePreference = SubtitlePreference.track(resolvedTrack);
case _SubtitleSelectionSlot.secondary:
_sessionSecondarySubtitlePreference = SubtitlePreference.track(resolvedTrack);
}
}
_updatePlaybackSessionSubtitleSelection(session, selection);
if (mounted) _setPlayerState(() {});
}