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
@@ -60,6 +60,7 @@ void main() {
hasCommittedSelection: true,
committedTrack: committed,
nativeTrack: SubtitleTrack.off,
sessionPreference: null,
);
expect(result, isA<SubtitleIntentPreference>());
@@ -71,6 +72,45 @@ void main() {
expect(intent.isExternal, isTrue);
});
test('session subtitle intent wins over the committed outcome at an item boundary', () {
const sessionPreference = SubtitlePreference.intent(
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
);
final result = subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng', title: 'English', codec: 'srt'),
nativeTrack: const SubtitleTrack(id: '2', language: 'eng', title: 'English', codec: 'srt'),
sessionPreference: sessionPreference,
);
expect(result, sessionPreference);
});
test('session subtitle off stays off at an item boundary', () {
expect(
subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng'),
nativeTrack: const SubtitleTrack(id: '2', language: 'eng'),
sessionPreference: const SubtitlePreference.off(),
),
const SubtitlePreference.off(),
);
});
test('semantics-free session subtitle preference falls back to the committed flow', () {
final result = subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng', title: 'English', codec: 'srt'),
nativeTrack: SubtitleTrack.off,
sessionPreference: const SubtitlePreference.track(SubtitleTrack(id: 'source:9')),
);
expect(result, isA<SubtitleIntentPreference>());
expect((result! as SubtitleIntentPreference).intent.language, 'eng');
});
test('item-change subtitle preference derives forced-ness from a forced title (#1716)', () {
const committed = SubtitleTrack(id: 'source:4', title: 'FR Forced [ASS]', language: 'fra', codec: 'ass');
@@ -233,6 +273,55 @@ void main() {
expect(selection.secondarySourceStreamId, isNull);
});
test('a reload-path source subtitle pick becomes the session preference (#1785)', () {
// Picks that cannot switch locally go through a full reload and never
// reach the native remember chain; the authoritative source row still
// has to become the session preference — including its discriminating
// title — or a later fallback episode erases the choice.
final rows = [
MediaSubtitleTrack(
id: 3,
languageCode: 'eng',
title: 'Full Subtitles',
displayTitle: 'English',
codec: 'ass',
selected: true,
forced: false,
),
MediaSubtitleTrack(
id: 4,
languageCode: 'eng',
title: 'Signs & Songs',
displayTitle: 'English',
codec: 'ass',
selected: false,
forced: false,
),
];
final captured = sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.source(4), rows);
expect(captured, isA<SubtitleTrackPreference>());
expect((captured! as SubtitleTrackPreference).track.title, 'Signs & Songs');
// The captured preference crosses the next episode boundary as its
// intent, keeping the signs/dialogue distinction.
final carried = SubtitlePreference.demoteToIntent(captured);
expect(carried, isA<SubtitleIntentPreference>());
expect((carried! as SubtitleIntentPreference).intent.title, 'Signs & Songs');
expect((carried as SubtitleIntentPreference).intent.language, 'eng');
});
test('a reload-path off choice and a stale row id capture correctly', () {
expect(
sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.off(), const []),
const SubtitlePreference.off(),
);
// A row the catalog no longer carries must not overwrite the session
// preference with a fabricated pick.
final rows = [MediaSubtitleTrack(id: 3, languageCode: 'eng', codec: 'ass', selected: false, forced: false)];
expect(sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.source(99), rows), isNull);
});
test('a secondary-only change keeps the primary declined carry alive (#1785)', () {
const declined = SubtitlePreference.intent(
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),