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:
@@ -160,6 +160,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
committedTrack: committedSubtitleSelection?.primaryTrack,
|
committedTrack: committedSubtitleSelection?.primaryTrack,
|
||||||
nativeTrack: currentPlayer.state.track.subtitle,
|
nativeTrack: currentPlayer.state.track.subtitle,
|
||||||
declinedPreference: committedSubtitleSelection?.declinedPreference,
|
declinedPreference: committedSubtitleSelection?.declinedPreference,
|
||||||
|
sessionPreference: _sessionSubtitlePreference,
|
||||||
);
|
);
|
||||||
final secondarySubtitlePreference = followServerSelections
|
final secondarySubtitlePreference = followServerSelections
|
||||||
? null
|
? null
|
||||||
@@ -167,6 +168,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
hasCommittedSelection: committedSubtitleSelection != null,
|
hasCommittedSelection: committedSubtitleSelection != null,
|
||||||
committedTrack: committedSubtitleSelection?.secondaryTrack,
|
committedTrack: committedSubtitleSelection?.secondaryTrack,
|
||||||
nativeTrack: currentPlayer.state.track.secondarySubtitle,
|
nativeTrack: currentPlayer.state.track.secondarySubtitle,
|
||||||
|
sessionPreference: _sessionSecondarySubtitlePreference,
|
||||||
);
|
);
|
||||||
await _reloadMediaInPlace(
|
await _reloadMediaInPlace(
|
||||||
metadata: episodeMetadata,
|
metadata: episodeMetadata,
|
||||||
@@ -178,6 +180,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
// meaningless on the new item, so let preferences pick the track.
|
// meaningless on the new item, so let preferences pick the track.
|
||||||
useCurrentAudioStreamSelection: false,
|
useCurrentAudioStreamSelection: false,
|
||||||
preserveCurrentTrackSelection: !followServerSelections,
|
preserveCurrentTrackSelection: !followServerSelections,
|
||||||
|
preservedAudioTrack: _sessionAudioPreference,
|
||||||
preservedSubtitleTrack: primarySubtitlePreference,
|
preservedSubtitleTrack: primarySubtitlePreference,
|
||||||
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
|
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
|
||||||
reason: 'episode navigation',
|
reason: 'episode navigation',
|
||||||
@@ -222,6 +225,28 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
player == currentPlayer &&
|
player == currentPlayer &&
|
||||||
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource);
|
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource);
|
||||||
bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded;
|
bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded;
|
||||||
|
void rememberSourceAudioPreference() {
|
||||||
|
final streamId = newAudioStreamId;
|
||||||
|
final mediaInfo = _currentMediaInfo;
|
||||||
|
if (streamId == null || mediaInfo == null) return;
|
||||||
|
for (final row in mediaInfo.audioTracks) {
|
||||||
|
if (row.id == streamId) {
|
||||||
|
_sessionAudioPreference = PlaybackSubtitleResolver.audioTrackForSource(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void rememberSourceSubtitlePreference() {
|
||||||
|
final choice = newSubtitleChoice;
|
||||||
|
final mediaInfo = _currentMediaInfo;
|
||||||
|
if (choice == null || mediaInfo == null) return;
|
||||||
|
// The local-switch path records through the screen's remember chain;
|
||||||
|
// this covers picks that had to reload, whose resolved outcome never
|
||||||
|
// reaches _rememberNativeSubtitleSelectionForSlot.
|
||||||
|
final preference = sessionPreferenceForSourceSubtitleChoice(choice, mediaInfo.subtitleTracks);
|
||||||
|
if (preference != null) _sessionSubtitlePreference = preference;
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot the backend client before subtitle selection can cross an
|
// Snapshot the backend client before subtitle selection can cross an
|
||||||
// async boundary or the profile-scoped context can disappear.
|
// async boundary or the profile-scoped context can disappear.
|
||||||
@@ -276,6 +301,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
|
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
|
||||||
final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice;
|
final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice;
|
||||||
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
|
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
|
||||||
|
rememberSourceAudioPreference();
|
||||||
|
rememberSourceSubtitlePreference();
|
||||||
return PlaybackSourceChangeOutcome.unchanged;
|
return PlaybackSourceChangeOutcome.unchanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +350,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
transitionLease: transitionLease,
|
transitionLease: transitionLease,
|
||||||
reason: 'source switch',
|
reason: 'source switch',
|
||||||
);
|
);
|
||||||
|
if (outcome == _MediaReloadOutcome.opened) {
|
||||||
|
rememberSourceAudioPreference();
|
||||||
|
rememberSourceSubtitlePreference();
|
||||||
|
}
|
||||||
return switch (outcome) {
|
return switch (outcome) {
|
||||||
_MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied,
|
_MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied,
|
||||||
_MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy,
|
_MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy,
|
||||||
@@ -562,6 +593,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
final initializationSubtitleTrack = preservesRequestedSubtitleSource
|
final initializationSubtitleTrack = preservesRequestedSubtitleSource
|
||||||
? currentSubtitleTrack
|
? currentSubtitleTrack
|
||||||
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
|
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
|
||||||
|
// Same boundary rule for audio: native and Jellyfin stream ids are
|
||||||
|
// reused per item, so a cross-source carry keeps only its semantics —
|
||||||
|
// an identity match against a reused id would latch by ordinal and
|
||||||
|
// bypass the evidence bands' ambiguity decline.
|
||||||
|
final initializationAudioTrack = preservesRequestedSubtitleSource || currentAudioTrack == null
|
||||||
|
? currentAudioTrack
|
||||||
|
: itemAgnosticAudioCarry(currentAudioTrack);
|
||||||
try {
|
try {
|
||||||
// Eager identity-only: the loading UI shows the new title immediately,
|
// Eager identity-only: the loading UI shows the new title immediately,
|
||||||
// while the selection/source state flips with the session commit at
|
// while the selection/source state flips with the session commit at
|
||||||
@@ -598,6 +636,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
preferredVersionSignature: preferredVersionSignature,
|
preferredVersionSignature: preferredVersionSignature,
|
||||||
qualityPreset: targetQualityPreset,
|
qualityPreset: targetQualityPreset,
|
||||||
selectedAudioStreamId: targetAudioStreamId,
|
selectedAudioStreamId: targetAudioStreamId,
|
||||||
|
preferredAudioTrack: initializationAudioTrack,
|
||||||
preferredSubtitleTrack: initializationSubtitleTrack,
|
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
@@ -617,7 +656,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
|
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
result: result,
|
result: result,
|
||||||
preferredAudioTrack: currentAudioTrack,
|
preferredAudioTrack: initializationAudioTrack,
|
||||||
preferredSubtitleTrack: currentSubtitleTrack,
|
preferredSubtitleTrack: currentSubtitleTrack,
|
||||||
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
||||||
preserveSubtitleSourceIdentity:
|
preserveSubtitleSourceIdentity:
|
||||||
@@ -771,7 +810,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
plexClient: plexClient,
|
plexClient: plexClient,
|
||||||
getProfileSettings: () => userProfileProvider.profileSettings,
|
getProfileSettings: () => userProfileProvider.profileSettings,
|
||||||
preferredAudioTrack: currentAudioTrack,
|
preferredAudioTrack: initializationAudioTrack,
|
||||||
// A declined carry stays alive for the native passes: freezing the
|
// A declined carry stays alive for the native passes: freezing the
|
||||||
// resolver's off verdict here would turn a metadata mismatch into a
|
// resolver's off verdict here would turn a metadata mismatch into a
|
||||||
// navigation-priority off that no late track can undo (#1785).
|
// navigation-priority off that no late track can undo (#1785).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
selectedMediaSourceId: _requestedMediaSourceId,
|
selectedMediaSourceId: _requestedMediaSourceId,
|
||||||
qualityPreset: _selectedQualityPreset,
|
qualityPreset: _selectedQualityPreset,
|
||||||
selectedAudioStreamId: _selectedAudioStreamId,
|
selectedAudioStreamId: _selectedAudioStreamId,
|
||||||
|
preferredAudioTrack: _preferredAudioTrack,
|
||||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ bool playerChromeStartsVisible({required bool isTv}) => !isTv;
|
|||||||
/// boundary; native state is a fallback for sessions created before
|
/// boundary; native state is a fallback for sessions created before
|
||||||
/// source-backed selection was recorded.
|
/// 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
|
/// [declinedPreference] is the committed selection's unserved carry: its off
|
||||||
/// is fallout from a metadata mismatch, not a viewer choice, so it must not
|
/// is fallout from a metadata mismatch, not a viewer choice, so it must not
|
||||||
/// harden into an explicit off on the next item (#1785).
|
/// harden into an explicit off on the next item (#1785).
|
||||||
@@ -161,7 +165,10 @@ SubtitlePreference? subtitlePreferenceForItemChange({
|
|||||||
required SubtitleTrack? committedTrack,
|
required SubtitleTrack? committedTrack,
|
||||||
required SubtitleTrack? nativeTrack,
|
required SubtitleTrack? nativeTrack,
|
||||||
SubtitlePreference? declinedPreference,
|
SubtitlePreference? declinedPreference,
|
||||||
|
SubtitlePreference? sessionPreference,
|
||||||
}) {
|
}) {
|
||||||
|
final sessionIntent = SubtitlePreference.demoteToIntent(sessionPreference);
|
||||||
|
if (sessionIntent != null) return sessionIntent;
|
||||||
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
|
SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) {
|
||||||
if (track == null) return null;
|
if (track == null) return null;
|
||||||
if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.off() : 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.
|
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
||||||
/// They are mutually exclusive by construction — entry points bail while a
|
/// They are mutually exclusive by construction — entry points bail while a
|
||||||
/// transition is in flight.
|
/// transition is in flight.
|
||||||
@@ -425,6 +452,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
AudioTrack? _preferredAudioTrack;
|
AudioTrack? _preferredAudioTrack;
|
||||||
SubtitlePreference? _preferredSubtitleTrack;
|
SubtitlePreference? _preferredSubtitleTrack;
|
||||||
SubtitlePreference? _preferredSecondarySubtitleTrack;
|
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;
|
bool _serverSupportsTranscoding = false;
|
||||||
// Kicked off early in the player initialization attempt for online non-live playback so
|
// 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)
|
// 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,
|
preferredVersionSignature: widget.preferredVersionSignature,
|
||||||
qualityPreset: _selectedQualityPreset,
|
qualityPreset: _selectedQualityPreset,
|
||||||
selectedAudioStreamId: _selectedAudioStreamId,
|
selectedAudioStreamId: _selectedAudioStreamId,
|
||||||
|
preferredAudioTrack: _preferredAudioTrack,
|
||||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
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 {
|
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
|
||||||
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
|
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
|
||||||
@@ -1919,10 +1960,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
required _SubtitleSelectionSlot slot,
|
required _SubtitleSelectionSlot slot,
|
||||||
int? sourceStreamId,
|
int? sourceStreamId,
|
||||||
}) {
|
}) {
|
||||||
|
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;
|
final session = _playbackSession;
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
final currentSelection = session.subtitleSelection;
|
final currentSelection = session.subtitleSelection;
|
||||||
if (track.id == SubtitleTrack.off.id) {
|
|
||||||
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
|
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
|
||||||
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
|
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
|
||||||
// Dropping only the secondary must not relabel the primary's
|
// Dropping only the secondary must not relabel the primary's
|
||||||
@@ -1938,6 +1985,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final session = _playbackSession;
|
||||||
|
if (session == null) return;
|
||||||
|
final currentSelection = session.subtitleSelection;
|
||||||
|
|
||||||
final info = _currentMediaInfo;
|
final info = _currentMediaInfo;
|
||||||
final currentPlayer = player;
|
final currentPlayer = player;
|
||||||
|
|
||||||
@@ -1989,6 +2040,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
sourceTrack: sourceTrack,
|
sourceTrack: sourceTrack,
|
||||||
sidecar: sidecar,
|
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);
|
_updatePlaybackSessionSubtitleSelection(session, selection);
|
||||||
if (mounted) _setPlayerState(() {});
|
if (mounted) _setPlayerState(() {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
final preset = options.qualityPreset;
|
final preset = options.qualityPreset;
|
||||||
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
|
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
|
||||||
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
|
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 requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
|
||||||
final int? maxStreamingBitrate = wantsOriginal
|
final int? maxStreamingBitrate = wantsOriginal
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ class PlaybackInitializationOptions {
|
|||||||
/// server pick".
|
/// server pick".
|
||||||
final int? selectedAudioStreamId;
|
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
|
/// Preferred subtitle carried across navigation/reloads. Backends that put
|
||||||
/// embedded subtitles in the rendition can use this during negotiation;
|
/// embedded subtitles in the rendition can use this during negotiation;
|
||||||
/// sidecar-capable backends keep subtitle delivery independent.
|
/// sidecar-capable backends keep subtitle delivery independent.
|
||||||
@@ -62,6 +69,7 @@ class PlaybackInitializationOptions {
|
|||||||
this.qualityPreset = TranscodeQualityPreset.original,
|
this.qualityPreset = TranscodeQualityPreset.original,
|
||||||
this.audioQualityPreset,
|
this.audioQualityPreset,
|
||||||
this.selectedAudioStreamId,
|
this.selectedAudioStreamId,
|
||||||
|
this.preferredAudioTrack,
|
||||||
this.preferredSubtitleTrack,
|
this.preferredSubtitleTrack,
|
||||||
this.sessionIdentifier,
|
this.sessionIdentifier,
|
||||||
this.transcodeSessionId,
|
this.transcodeSessionId,
|
||||||
|
|||||||
@@ -329,18 +329,23 @@ class PlaybackSubtitleResolver {
|
|||||||
return choices[(normalizedCurrentIndex + advances) % choices.length];
|
return choices[(normalizedCurrentIndex + advances) % choices.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
|
/// Stable semantic descriptor for a source audio row — the audio twin of
|
||||||
return [
|
/// [subtitleTrackForSource]. The row's own title comes first: server
|
||||||
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
|
/// display titles collapse to the bare language and cannot tell a
|
||||||
AudioTrack(
|
/// commentary or alternate mix from the main track on another item.
|
||||||
|
static AudioTrack audioTrackForSource(MediaAudioTrack track) {
|
||||||
|
return AudioTrack(
|
||||||
id: 'source:${track.id}',
|
id: 'source:${track.id}',
|
||||||
title: track.displayTitle ?? track.title ?? track.language,
|
title: track.title ?? track.displayTitle ?? track.language,
|
||||||
language: track.languageCode ?? track.language,
|
language: track.languageCode ?? track.language,
|
||||||
codec: track.codec,
|
codec: track.codec,
|
||||||
channels: track.channels,
|
channels: track.channels,
|
||||||
isDefault: track.selected,
|
isDefault: track.selected,
|
||||||
),
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
|
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
|
||||||
|
return [for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[]) audioTrackForSource(track)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import 'plex_lyrics_parser.dart';
|
|||||||
import 'plex_mappers.dart';
|
import 'plex_mappers.dart';
|
||||||
import 'plex_playback_mapper.dart';
|
import 'plex_playback_mapper.dart';
|
||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
|
import 'track_selection_service.dart';
|
||||||
|
|
||||||
part 'plex_client/parts/live_tv.dart';
|
part 'plex_client/parts/live_tv.dart';
|
||||||
part 'plex_client/parts/playlists.dart';
|
part 'plex_client/parts/playlists.dart';
|
||||||
@@ -3252,6 +3253,10 @@ class PlexClient
|
|||||||
if (!data.hasValidVideoUrl) {
|
if (!data.hasValidVideoUrl) {
|
||||||
throw PlaybackException(t.messages.fileInfoNotAvailable, reason: PlaybackFailureReason.noPlayableSource);
|
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
|
// Tracks consult the music preset — [qualityPreset] is video-shaped
|
||||||
// (resolution/videoQuality) and is ignored for audio.
|
// (resolution/videoQuality) and is ignored for audio.
|
||||||
@@ -3286,7 +3291,9 @@ class PlexClient
|
|||||||
return _transcodeFallbackResult(data, result.outcome, options);
|
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(
|
final result = await buildTranscodeStartPath(
|
||||||
ratingKey: options.metadata.id,
|
ratingKey: options.metadata.id,
|
||||||
mediaIndex: data.selectedMediaIndex,
|
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(
|
return PlaybackInitializationResult(
|
||||||
@@ -3323,6 +3330,7 @@ class PlexClient
|
|||||||
mediaInfo: data.mediaInfo,
|
mediaInfo: data.mediaInfo,
|
||||||
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
|
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
|
activeAudioStreamId: carriedAudioStreamId,
|
||||||
playMethod: 'DirectPlay',
|
playMethod: 'DirectPlay',
|
||||||
playSessionId: options.sessionIdentifier,
|
playSessionId: options.sessionIdentifier,
|
||||||
selectedMediaIndex: data.selectedMediaIndex,
|
selectedMediaIndex: data.selectedMediaIndex,
|
||||||
@@ -3340,8 +3348,9 @@ class PlexClient
|
|||||||
PlaybackInitializationResult _transcodeFallbackResult(
|
PlaybackInitializationResult _transcodeFallbackResult(
|
||||||
PlexVideoPlaybackData data,
|
PlexVideoPlaybackData data,
|
||||||
TranscodeDecisionOutcome outcome,
|
TranscodeDecisionOutcome outcome,
|
||||||
PlaybackInitializationOptions options,
|
PlaybackInitializationOptions options, {
|
||||||
) {
|
int? activeAudioStreamId,
|
||||||
|
}) {
|
||||||
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
|
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
|
||||||
? TranscodeFallbackReason.directPlayOnly
|
? TranscodeFallbackReason.directPlayOnly
|
||||||
: TranscodeFallbackReason.decisionFailed;
|
: TranscodeFallbackReason.decisionFailed;
|
||||||
@@ -3354,6 +3363,7 @@ class PlexClient
|
|||||||
isOffline: false,
|
isOffline: false,
|
||||||
isTranscoding: false,
|
isTranscoding: false,
|
||||||
fallbackReason: fallbackReason,
|
fallbackReason: fallbackReason,
|
||||||
|
activeAudioStreamId: activeAudioStreamId,
|
||||||
playMethod: 'DirectPlay',
|
playMethod: 'DirectPlay',
|
||||||
playSessionId: options.sessionIdentifier,
|
playSessionId: options.sessionIdentifier,
|
||||||
selectedMediaIndex: data.selectedMediaIndex,
|
selectedMediaIndex: data.selectedMediaIndex,
|
||||||
|
|||||||
@@ -357,93 +357,147 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
|
|||||||
/// signal is a title like "Swedish" are common, and declining them turned the
|
/// 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
|
/// viewer's subtitles off on every episode advance. Codec/external parity is
|
||||||
/// never sufficient evidence on its own (an arbitrary untagged row would
|
/// never sufficient evidence on its own (an arbitrary untagged row would
|
||||||
/// reintroduce the #1716 wrong-track class); it only breaks ties WITHIN the
|
/// reintroduce the #1716 wrong-track class); technical parity only breaks
|
||||||
/// title-matched set, and a residual tie declines rather than guesses.
|
/// 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) {
|
MediaSubtitleTrack? findSourceTrackForIntent(SubtitleIntent intent, List<MediaSubtitleTrack> sourceTracks) {
|
||||||
return _findTrackForIntent(
|
return _findTrackByEvidenceBands(
|
||||||
intent,
|
|
||||||
sourceTracks,
|
sourceTracks,
|
||||||
|
intentLanguage: intent.language,
|
||||||
isSelectable: (_) => true,
|
isSelectable: (_) => true,
|
||||||
|
classMatches: (row) => row.effectiveForced == intent.forced,
|
||||||
language: (row) => row.languageCode ?? row.language,
|
language: (row) => row.languageCode ?? row.language,
|
||||||
effectiveForced: (row) => row.effectiveForced,
|
|
||||||
titleScore: (row) => _titleScore(intent.title, row.title, row.displayTitle),
|
titleScore: (row) => _titleScore(intent.title, row.title, row.displayTitle),
|
||||||
codec: (row) => row.codec,
|
codecMatches: (row) => _subtitleCodecsMatch(intent.codec, row.codec),
|
||||||
isExternal: (row) => row.isExternal,
|
extraScore: (row) => intent.isExternal == row.isExternal ? 1 : 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native-track twin of [findSourceTrackForIntent], for catalogs the source
|
/// Native-track twin of [findSourceTrackForIntent], for catalogs the source
|
||||||
/// side cannot describe (legacy offline sidecars) and late-arriving tracks.
|
/// side cannot describe (legacy offline sidecars) and late-arriving tracks.
|
||||||
SubtitleTrack? findNativeTrackForIntent(SubtitleIntent intent, List<SubtitleTrack> tracks) {
|
SubtitleTrack? findNativeTrackForIntent(SubtitleIntent intent, List<SubtitleTrack> tracks) {
|
||||||
return _findTrackForIntent(
|
return _findTrackByEvidenceBands(
|
||||||
intent,
|
|
||||||
tracks,
|
tracks,
|
||||||
|
intentLanguage: intent.language,
|
||||||
isSelectable: (track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id,
|
isSelectable: (track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id,
|
||||||
|
classMatches: (track) => track.effectiveForced == intent.forced,
|
||||||
language: (track) => track.language,
|
language: (track) => track.language,
|
||||||
effectiveForced: (track) => track.effectiveForced,
|
|
||||||
titleScore: (track) => _titleScore(intent.title, track.title, null),
|
titleScore: (track) => _titleScore(intent.title, track.title, null),
|
||||||
codec: (track) => track.codec,
|
codecMatches: (track) => _subtitleCodecsMatch(intent.codec, track.codec),
|
||||||
isExternal: (track) => track.isExternal,
|
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
|
/// Candidates are compared lexicographically, strongest evidence first:
|
||||||
/// match starts at 10 while a title-evidence match tops out at 9
|
/// declared-language parity, then the semantic title/role match, then
|
||||||
/// (codec 5 + title 3 + external 1), so the two can never tie and language
|
/// technical parity (codec, then channels/external). Each tier only breaks
|
||||||
/// always outranks a title coincidence.
|
/// ties left by the tiers above it — a codec that changed between episodes
|
||||||
T? _findTrackForIntent<T extends Object>(
|
/// can never outvote the title that names the viewer's track, and language
|
||||||
SubtitleIntent intent,
|
/// 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, {
|
List<T> candidates, {
|
||||||
|
required String? intentLanguage,
|
||||||
required bool Function(T) isSelectable,
|
required bool Function(T) isSelectable,
|
||||||
|
required bool Function(T) classMatches,
|
||||||
required String? Function(T) language,
|
required String? Function(T) language,
|
||||||
required bool Function(T) effectiveForced,
|
|
||||||
required int Function(T) titleScore,
|
required int Function(T) titleScore,
|
||||||
required String? Function(T) codec,
|
required bool Function(T) codecMatches,
|
||||||
required bool Function(T) isExternal,
|
required int Function(T) extraScore,
|
||||||
}) {
|
}) {
|
||||||
T? bestMatch;
|
T? bestMatch;
|
||||||
var bestScore = -1;
|
List<int>? bestKey;
|
||||||
var bestIsTitleEvidence = false;
|
|
||||||
var bestIsAmbiguous = false;
|
var bestIsAmbiguous = false;
|
||||||
for (final candidate in candidates) {
|
for (final candidate in candidates) {
|
||||||
if (!isSelectable(candidate)) continue;
|
if (!isSelectable(candidate)) continue;
|
||||||
if (effectiveForced(candidate) != intent.forced) continue;
|
if (!classMatches(candidate)) continue;
|
||||||
|
|
||||||
final candidateLanguage = language(candidate);
|
final candidateLanguage = language(candidate);
|
||||||
final candidateTitleScore = titleScore(candidate);
|
final candidateTitleScore = titleScore(candidate);
|
||||||
final hasLanguageParity = intent.language != null && candidateLanguage != null;
|
final hasLanguageParity = intentLanguage != null && candidateLanguage != null;
|
||||||
var score = 0;
|
|
||||||
if (hasLanguageParity) {
|
if (hasLanguageParity) {
|
||||||
// A declared language on both sides stays authoritative: a
|
// A declared language on both sides stays authoritative: a
|
||||||
// contradiction declines no matter what the title says.
|
// contradiction declines no matter what the title says.
|
||||||
if (!_languagesMatch(intent.language, candidateLanguage)) continue;
|
if (!_languagesMatch(intentLanguage, candidateLanguage)) continue;
|
||||||
score += 10;
|
|
||||||
} else if (candidateTitleScore < 3) {
|
} else if (candidateTitleScore < 3) {
|
||||||
// Language evidence is missing on at least one side; only a real
|
// Language evidence is missing on at least one side; only a real
|
||||||
// title match may serve the intent then.
|
// title match may serve the intent then.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (_subtitleCodecsMatch(intent.codec, codec(candidate))) score += 5;
|
final key = [
|
||||||
score += candidateTitleScore;
|
hasLanguageParity ? 1 : 0,
|
||||||
if (intent.isExternal == isExternal(candidate)) score += 1;
|
candidateTitleScore,
|
||||||
if (score > bestScore) {
|
codecMatches(candidate) ? 1 : 0,
|
||||||
bestScore = score;
|
extraScore(candidate),
|
||||||
|
];
|
||||||
|
final comparison = bestKey == null ? 1 : _compareEvidenceKeys(key, bestKey);
|
||||||
|
if (comparison > 0) {
|
||||||
|
bestKey = key;
|
||||||
bestMatch = candidate;
|
bestMatch = candidate;
|
||||||
bestIsTitleEvidence = !hasLanguageParity;
|
|
||||||
bestIsAmbiguous = false;
|
bestIsAmbiguous = false;
|
||||||
} else if (score == bestScore && bestIsTitleEvidence) {
|
} else if (comparison == 0) {
|
||||||
// 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.
|
|
||||||
bestIsAmbiguous = true;
|
bestIsAmbiguous = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestIsTitleEvidence && bestIsAmbiguous) return null;
|
if (bestIsAmbiguous) return null;
|
||||||
return bestMatch;
|
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
|
/// Find the MPV audio track that matches a Plex audio track
|
||||||
AudioTrack? findMpvTrackForPlexAudio(
|
AudioTrack? findMpvTrackForPlexAudio(
|
||||||
MediaAudioTrack plexTrack,
|
MediaAudioTrack plexTrack,
|
||||||
@@ -716,10 +770,6 @@ class TrackSelectionService {
|
|||||||
return null;
|
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) {
|
AudioTrack? findAudioTrackByProfile(List<AudioTrack> availableTracks, MediaServerUserProfile profile) {
|
||||||
if (availableTracks.isEmpty || !profile.autoSelectAudio) return null;
|
if (availableTracks.isEmpty || !profile.autoSelectAudio) return null;
|
||||||
|
|
||||||
@@ -947,12 +997,29 @@ class TrackSelectionService {
|
|||||||
|
|
||||||
AudioTrack? trackToSelect;
|
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) {
|
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) {
|
if (trackToSelect != null) {
|
||||||
return TrackSelectionResult(trackToSelect, TrackSelectionPriority.navigation);
|
return TrackSelectionResult(trackToSelect, TrackSelectionPriority.navigation);
|
||||||
}
|
}
|
||||||
|
appLogger.d('Audio carry declined: ${preferredAudioTrack.language}/${preferredAudioTrack.title}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Check server-selected track from media info
|
// Priority 2: Check server-selected track from media info
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ void main() {
|
|||||||
hasCommittedSelection: true,
|
hasCommittedSelection: true,
|
||||||
committedTrack: committed,
|
committedTrack: committed,
|
||||||
nativeTrack: SubtitleTrack.off,
|
nativeTrack: SubtitleTrack.off,
|
||||||
|
sessionPreference: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, isA<SubtitleIntentPreference>());
|
expect(result, isA<SubtitleIntentPreference>());
|
||||||
@@ -71,6 +72,45 @@ void main() {
|
|||||||
expect(intent.isExternal, isTrue);
|
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)', () {
|
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');
|
const committed = SubtitleTrack(id: 'source:4', title: 'FR Forced [ASS]', language: 'fra', codec: 'ass');
|
||||||
|
|
||||||
@@ -233,6 +273,55 @@ void main() {
|
|||||||
expect(selection.secondarySourceStreamId, isNull);
|
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)', () {
|
test('a secondary-only change keeps the primary declined carry alive (#1785)', () {
|
||||||
const declined = SubtitlePreference.intent(
|
const declined = SubtitlePreference.intent(
|
||||||
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
|
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
|
||||||
|
|||||||
@@ -64,6 +64,56 @@ JellyfinClient _clientWithPlaybackInfo(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({PlaybackInitializationResult result, Uri playbackInfoUri, Map<String, dynamic> playbackInfoBody})>
|
||||||
|
_initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferredAudioTrack}) async {
|
||||||
|
late Uri playbackInfoUri;
|
||||||
|
late String playbackInfoBody;
|
||||||
|
final client = _clientWithPlaybackInfo(
|
||||||
|
(request) async {
|
||||||
|
playbackInfoUri = request.url;
|
||||||
|
playbackInfoBody = request.body;
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{'Id': 'src-1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemSources: [
|
||||||
|
{
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'MediaStreams': [
|
||||||
|
{'Index': 0, 'Type': 'Video'},
|
||||||
|
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true},
|
||||||
|
{'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'Title': 'Main'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final result = await client.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.episode,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
selectedAudioStreamId: selectedAudioStreamId,
|
||||||
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
result: result,
|
||||||
|
playbackInfoUri: playbackInfoUri,
|
||||||
|
playbackInfoBody: jsonDecode(playbackInfoBody) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Serves [routes] as JSON keyed by request path and records the last URL seen
|
/// Serves [routes] as JSON keyed by request path and records the last URL seen
|
||||||
/// for each path; every other path answers 404.
|
/// for each path; every other path answers 404.
|
||||||
({JellyfinClient client, Map<String, Uri> requests}) _routedClient(Map<String, Object> routes) {
|
({JellyfinClient client, Map<String, Uri> requests}) _routedClient(Map<String, Object> routes) {
|
||||||
@@ -1342,6 +1392,38 @@ void main() {
|
|||||||
expect(uri.queryParameters['Container'], 'mkv');
|
expect(uri.queryParameters['Container'], 'mkv');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('semantic carried audio resolves against the selected source before PlaybackInfo negotiation', () async {
|
||||||
|
final initialized = await _initializeJellyfinAudioCarry(
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '4');
|
||||||
|
expect(initialized.playbackInfoBody['AudioStreamIndex'], 4);
|
||||||
|
expect(initialized.result.activeAudioStreamId, 4);
|
||||||
|
expect(initialized.result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4).selected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit Jellyfin audio stream wins over a conflicting semantic carry', () async {
|
||||||
|
final initialized = await _initializeJellyfinAudioCarry(
|
||||||
|
selectedAudioStreamId: 1,
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '1');
|
||||||
|
expect(initialized.playbackInfoBody['AudioStreamIndex'], 1);
|
||||||
|
expect(initialized.result.activeAudioStreamId, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unresolvable semantic audio carry lets Jellyfin choose the stream', () async {
|
||||||
|
final initialized = await _initializeJellyfinAudioCarry(
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'swe'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.playbackInfoUri.queryParameters.containsKey('AudioStreamIndex'), isFalse);
|
||||||
|
expect(initialized.playbackInfoBody.containsKey('AudioStreamIndex'), isFalse);
|
||||||
|
expect(initialized.result.activeAudioStreamId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('stale selected audio stream is not sent for a source without that stream', () async {
|
test('stale selected audio stream is not sent for a source without that stream', () async {
|
||||||
Uri? playbackInfoUri;
|
Uri? playbackInfoUri;
|
||||||
String? playbackInfoBody;
|
String? playbackInfoBody;
|
||||||
|
|||||||
@@ -692,6 +692,25 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('audio source descriptor keeps the discriminating row title', () {
|
||||||
|
// Server display titles collapse to the bare language; a commentary or
|
||||||
|
// alternate mix is only identifiable by the row's own title.
|
||||||
|
final row = MediaAudioTrack(
|
||||||
|
id: 7,
|
||||||
|
languageCode: 'eng',
|
||||||
|
title: 'Commentary',
|
||||||
|
displayTitle: 'English',
|
||||||
|
codec: 'ac3',
|
||||||
|
channels: 6,
|
||||||
|
selected: false,
|
||||||
|
);
|
||||||
|
final track = PlaybackSubtitleResolver.audioTrackForSource(row);
|
||||||
|
expect(track.id, 'source:7');
|
||||||
|
expect(track.title, 'Commentary');
|
||||||
|
expect(track.language, 'eng');
|
||||||
|
expect(track.channels, 6);
|
||||||
|
});
|
||||||
|
|
||||||
test('selected embedded subtitle keeps sidecars out of the open', () {
|
test('selected embedded subtitle keeps sidecars out of the open', () {
|
||||||
final result = PlaybackSubtitleResolver.resolve(
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:plezy/media/media_backend.dart';
|
|||||||
|
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
import 'package:plezy/media/media_source_info.dart';
|
import 'package:plezy/media/media_source_info.dart';
|
||||||
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
import 'package:plezy/services/plex_api_cache.dart';
|
import 'package:plezy/services/plex_api_cache.dart';
|
||||||
@@ -34,6 +35,91 @@ void main() {
|
|||||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||||
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
||||||
|
|
||||||
|
Future<({PlaybackInitializationResult result, Uri decisionUri})> initializeTranscodeAudio({
|
||||||
|
int? selectedAudioStreamId,
|
||||||
|
AudioTrack? preferredAudioTrack,
|
||||||
|
}) async {
|
||||||
|
late Uri decisionUri;
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
if (request.url.path == '/library/metadata/42') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {
|
||||||
|
'Metadata': [
|
||||||
|
{
|
||||||
|
'ratingKey': '42',
|
||||||
|
'type': 'episode',
|
||||||
|
'title': 'Episode',
|
||||||
|
'Media': [
|
||||||
|
{
|
||||||
|
'id': 7,
|
||||||
|
'container': 'mkv',
|
||||||
|
'Part': [
|
||||||
|
{
|
||||||
|
'id': 99,
|
||||||
|
'key': '/library/parts/99/file.mkv',
|
||||||
|
'Stream': [
|
||||||
|
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
||||||
|
{
|
||||||
|
'streamType': 2,
|
||||||
|
'id': 301,
|
||||||
|
'index': 0,
|
||||||
|
'codec': 'aac',
|
||||||
|
'languageCode': 'eng',
|
||||||
|
'title': 'Original',
|
||||||
|
'selected': true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'streamType': 2,
|
||||||
|
'id': 305,
|
||||||
|
'index': 1,
|
||||||
|
'codec': 'flac',
|
||||||
|
'languageCode': 'jpn',
|
||||||
|
'title': 'Main',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||||||
|
decisionUri = request.url;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('unexpected request', 500);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await client.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.episode, serverId: 'server-id'),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
selectedAudioStreamId: selectedAudioStreamId,
|
||||||
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (result: result, decisionUri: decisionUri);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
||||||
return MediaSourceInfo(
|
return MediaSourceInfo(
|
||||||
videoUrl: 'https://plex.example.com/video.mkv',
|
videoUrl: 'https://plex.example.com/video.mkv',
|
||||||
@@ -68,6 +154,34 @@ void main() {
|
|||||||
expect(requests.single.url.queryParameters['allParts'], '1');
|
expect(requests.single.url.queryParameters['allParts'], '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('semantic carried audio is sent to the Plex transcode decision', () async {
|
||||||
|
final initialized = await initializeTranscodeAudio(
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.decisionUri.queryParameters['audioStreamID'], '305');
|
||||||
|
expect(initialized.result.activeAudioStreamId, 305);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit Plex audio stream wins over a conflicting semantic carry', () async {
|
||||||
|
final initialized = await initializeTranscodeAudio(
|
||||||
|
selectedAudioStreamId: 301,
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.decisionUri.queryParameters['audioStreamID'], '301');
|
||||||
|
expect(initialized.result.activeAudioStreamId, 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unresolvable semantic audio carry lets the Plex transcoder choose the stream', () async {
|
||||||
|
final initialized = await initializeTranscodeAudio(
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'swe'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialized.decisionUri.queryParameters.containsKey('audioStreamID'), isFalse);
|
||||||
|
expect(initialized.result.activeAudioStreamId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
|
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
|
||||||
final requests = <Uri>[];
|
final requests = <Uri>[];
|
||||||
final client = makeClient((request) async {
|
final client = makeClient((request) async {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import '../test_helpers/media_items.dart';
|
|||||||
// integration point (`selectAndApplyTracks`). We cover:
|
// integration point (`selectAndApplyTracks`). We cover:
|
||||||
//
|
//
|
||||||
// - `languageMatches` — direct, base-code, and ISO 639 variation matching.
|
// - `languageMatches` — direct, base-code, and ISO 639 variation matching.
|
||||||
// - `findBestTrackMatch` / `findBestAudioMatch` / `findBestSubtitleMatch` —
|
// - `findBestTrackMatch` / `findBestSubtitleMatch` —
|
||||||
// id+title+language exact, title+language, language-only, and the
|
// id+title+language exact, title+language, language-only, and the
|
||||||
// "auto"/"no" filtering rule.
|
// "auto"/"no" filtering rule.
|
||||||
// - `findAudioTrackByProfile` — picks the first preferred-language match,
|
// - `findAudioTrackByProfile` — picks the first preferred-language match,
|
||||||
@@ -232,49 +232,6 @@ void main() {
|
|||||||
// findBestTrackMatch (via the audio/subtitle wrappers)
|
// findBestTrackMatch (via the audio/subtitle wrappers)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
group('findBestAudioMatch', () {
|
|
||||||
final svc = _svc();
|
|
||||||
|
|
||||||
test('exact id + title + language match wins', () {
|
|
||||||
final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')];
|
|
||||||
final preferred = _audio('2', lang: 'eng', title: 'Surround');
|
|
||||||
expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to title + language when id differs', () {
|
|
||||||
final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')];
|
|
||||||
// Different id but matching title+language → tracks[1].
|
|
||||||
final preferred = _audio('999', lang: 'eng', title: 'Surround');
|
|
||||||
expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to language-only match', () {
|
|
||||||
final tracks = [_audio('1', lang: 'eng', title: 'Stereo')];
|
|
||||||
final preferred = _audio('999', lang: 'eng', title: 'Different');
|
|
||||||
expect(svc.findBestAudioMatch(tracks, preferred), tracks[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when no language match exists', () {
|
|
||||||
final tracks = [_audio('1', lang: 'fre')];
|
|
||||||
final preferred = _audio('1', lang: 'eng');
|
|
||||||
expect(svc.findBestAudioMatch(tracks, preferred), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters out auto and no tracks before matching', () {
|
|
||||||
final tracks = [AudioTrack.auto, AudioTrack.off, _audio('3', lang: 'eng')];
|
|
||||||
final preferred = _audio('3', lang: 'eng');
|
|
||||||
expect(svc.findBestAudioMatch(tracks, preferred), tracks[2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null on an empty list', () {
|
|
||||||
expect(svc.findBestAudioMatch(const [], _audio('1', lang: 'eng')), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when only auto/no tracks remain after filtering', () {
|
|
||||||
expect(svc.findBestAudioMatch([AudioTrack.auto, AudioTrack.off], _audio('1', lang: 'eng')), isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('findBestSubtitleMatch', () {
|
group('findBestSubtitleMatch', () {
|
||||||
final svc = _svc();
|
final svc = _svc();
|
||||||
|
|
||||||
@@ -352,6 +309,64 @@ void main() {
|
|||||||
expect(result.track, tracks[1]);
|
expect(result.track, tracks[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Priority 1: a cross-item semantic carry matches through the evidence bands', () {
|
||||||
|
// The carried track's id belongs to the previous episode; language and
|
||||||
|
// title must still find the equivalent native track here.
|
||||||
|
final tracks = [_audio('1', lang: 'eng', title: 'Main'), _audio('2', lang: 'eng', title: 'Commentary')];
|
||||||
|
final carried = _audio('source:99', lang: 'eng', title: 'Commentary');
|
||||||
|
final result = _svc().selectAudioTrack(tracks, carried);
|
||||||
|
expect(result!.priority, TrackSelectionPriority.navigation);
|
||||||
|
expect(result.track, tracks[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Priority 1: a declined audio carry falls to the server-selected track', () {
|
||||||
|
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
|
||||||
|
final info = _info(
|
||||||
|
audio: [
|
||||||
|
_plexAudio(1, language: 'eng', languageCode: 'eng', selected: true),
|
||||||
|
_plexAudio(2, language: 'fre', languageCode: 'fre'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// Swedish is gone on this episode: the carry declines instead of
|
||||||
|
// latching onto an arbitrary row, and the server's pick plays.
|
||||||
|
final result = _svc(info: info).selectAudioTrack(tracks, _audio('source:9', lang: 'swe'));
|
||||||
|
expect(result!.priority, TrackSelectionPriority.serverSelected);
|
||||||
|
expect(result.track.language, 'eng');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a demoted cross-item carry cannot latch a reused native id', () {
|
||||||
|
// Native ids are per-item ordinals: the previous episode's id '2' names
|
||||||
|
// a DIFFERENT track here. The boundary demotes the carry to semantics
|
||||||
|
// only; an indistinguishable same-language pair then declines to the
|
||||||
|
// server's choice instead of silently keeping the old ordinal.
|
||||||
|
final tracks = [
|
||||||
|
_audio('1', lang: 'eng', codec: 'aac', channels: 2),
|
||||||
|
_audio('2', lang: 'eng', codec: 'aac', channels: 2),
|
||||||
|
];
|
||||||
|
final info = _info(
|
||||||
|
audio: [
|
||||||
|
_plexAudio(1, language: 'eng', languageCode: 'eng', selected: true),
|
||||||
|
_plexAudio(2, language: 'eng', languageCode: 'eng'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final carriedRaw = _audio('2', lang: 'eng', codec: 'aac', channels: 2);
|
||||||
|
final carried = itemAgnosticAudioCarry(carriedRaw);
|
||||||
|
|
||||||
|
// Demotion swaps only the identity; the semantics stay intact.
|
||||||
|
expect(carried.id, carriedAudioTrackId);
|
||||||
|
expect(carried.language, 'eng');
|
||||||
|
expect(carried.channels, 2);
|
||||||
|
|
||||||
|
final result = _svc(info: info).selectAudioTrack(tracks, carried);
|
||||||
|
expect(result!.priority, TrackSelectionPriority.serverSelected);
|
||||||
|
|
||||||
|
// Control: the raw (un-demoted) carry would have identity-latched the
|
||||||
|
// reused id — the exact bypass the boundary demotion exists to prevent.
|
||||||
|
final latched = _svc(info: info).selectAudioTrack(tracks, carriedRaw);
|
||||||
|
expect(latched!.priority, TrackSelectionPriority.navigation);
|
||||||
|
expect(latched.track.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
test('Priority 2: Plex-selected track from media info', () {
|
test('Priority 2: Plex-selected track from media info', () {
|
||||||
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
|
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
|
||||||
final info = _info(
|
final info = _info(
|
||||||
@@ -1205,6 +1220,30 @@ void main() {
|
|||||||
];
|
];
|
||||||
expect(findSourceTrackForIntent(intent, rows)?.id, 2);
|
expect(findSourceTrackForIntent(intent, rows)?.id, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the semantic title outranks a retained codec across a codec flip', () {
|
||||||
|
// The signs track was re-encoded srt on this episode while the full
|
||||||
|
// dialogue track kept the old codec: the row NAMED by the carried
|
||||||
|
// title must win — technical parity only breaks ties the semantic
|
||||||
|
// tiers left.
|
||||||
|
const intent = SubtitleIntent(language: 'eng', forced: false, title: 'Signs/OP/ED', codec: 'ass');
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'eng', title: 'Full Subtitles', codec: 'ass'),
|
||||||
|
_plexSub(2, languageCode: 'eng', title: 'Signs/OP/ED', codec: 'subrip'),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(intent, rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an indistinguishable same-language pair declines rather than latching by order', () {
|
||||||
|
// A titleless carry cannot tell two equal same-language rows apart:
|
||||||
|
// guessing the first row would recreate the #1717 order latch.
|
||||||
|
const intent = SubtitleIntent(language: 'eng', forced: false, codec: 'subrip');
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'eng', codec: 'subrip'),
|
||||||
|
_plexSub(2, languageCode: 'eng', codec: 'subrip'),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(intent, rows), isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('findNativeTrackForIntent', () {
|
group('findNativeTrackForIntent', () {
|
||||||
@@ -1248,6 +1287,75 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('audio carry evidence bands', () {
|
||||||
|
test('bridges two- and three-letter language codes across episodes', () {
|
||||||
|
// The old carry compared languages with raw equality, so a 'sv' pick
|
||||||
|
// never found a 'swe'-tagged row on the next episode.
|
||||||
|
final rows = [_plexAudio(1, languageCode: 'eng'), _plexAudio(2, languageCode: 'swe')];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'sv'), rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the commentary/main distinction between same-language tracks', () {
|
||||||
|
// The old language-only tier returned the FIRST same-language row,
|
||||||
|
// flipping a commentary pick back to the main mix every episode.
|
||||||
|
final rows = [
|
||||||
|
_plexAudio(1, languageCode: 'eng', title: 'Main'),
|
||||||
|
_plexAudio(2, languageCode: 'eng', title: 'Commentary'),
|
||||||
|
];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', title: 'Commentary'), rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a unique title vouches for untagged tracks', () {
|
||||||
|
final rows = [_plexAudio(1, title: 'Main'), _plexAudio(2, title: 'Commentary')];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Commentary'), rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codec parity alone never vouches for an untagged track', () {
|
||||||
|
final rows = [_plexAudio(1, codec: 'ac3')];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Commentary', codec: 'ac3'), rows), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ambiguous same-title untagged pair declines rather than guesses', () {
|
||||||
|
final rows = [_plexAudio(1, title: 'Stereo'), _plexAudio(2, title: 'Stereo')];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Stereo'), rows), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a declared language contradiction is never rescued by a title', () {
|
||||||
|
final rows = [_plexAudio(1, languageCode: 'eng', title: 'Commentary')];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'swe', title: 'Commentary'), rows), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel count breaks ties between otherwise equal rows', () {
|
||||||
|
final rows = [
|
||||||
|
_plexAudio(1, languageCode: 'eng', channels: 2, codec: 'aac'),
|
||||||
|
_plexAudio(2, languageCode: 'eng', channels: 6, codec: 'aac'),
|
||||||
|
];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', channels: 6, codec: 'aac'), rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the native twin skips the auto and off sentinels', () {
|
||||||
|
final tracks = [AudioTrack.auto, AudioTrack.off, _audio('3', lang: 'eng')];
|
||||||
|
expect(findNativeAudioTrackForIntent(_audio('x', lang: 'eng'), tracks)?.id, '3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a commentary title outranks a retained codec across a codec flip', () {
|
||||||
|
final rows = [
|
||||||
|
_plexAudio(1, languageCode: 'eng', title: 'Main', codec: 'ac3'),
|
||||||
|
_plexAudio(2, languageCode: 'eng', title: 'Commentary', codec: 'aac'),
|
||||||
|
];
|
||||||
|
final carried = _audio('x', lang: 'eng', title: 'Commentary', codec: 'ac3');
|
||||||
|
expect(findSourceAudioTrackForIntent(carried, rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an indistinguishable same-language pair declines rather than latching by order', () {
|
||||||
|
final rows = [
|
||||||
|
_plexAudio(1, languageCode: 'eng', codec: 'aac', channels: 2),
|
||||||
|
_plexAudio(2, languageCode: 'eng', codec: 'aac', channels: 2),
|
||||||
|
];
|
||||||
|
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', codec: 'aac'), rows), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('selectSubtitleTrack - intent preferences (#1716/#1717)', () {
|
group('selectSubtitleTrack - intent preferences (#1716/#1717)', () {
|
||||||
const forcedIntent = SubtitlePreference.intent(
|
const forcedIntent = SubtitlePreference.intent(
|
||||||
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'),
|
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'),
|
||||||
|
|||||||
Reference in New Issue
Block a user