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
359 lines
15 KiB
Dart
359 lines
15 KiB
Dart
import 'package:collection/collection.dart';
|
|
|
|
import '../media/media_item.dart';
|
|
import '../media/media_server_user_profile.dart';
|
|
import '../media/media_source_info.dart';
|
|
import '../mpv/mpv.dart';
|
|
import '../utils/subtitle_forced_semantics.dart';
|
|
import 'playback_initialization_types.dart';
|
|
import 'subtitle_preference.dart';
|
|
import 'track_selection_service.dart';
|
|
|
|
/// A source-catalog subtitle choice.
|
|
///
|
|
/// Keeping "off" distinct from a numeric stream id prevents backend wire
|
|
/// conventions (notably Plex's `0`) from colliding with real Jellyfin ids.
|
|
final class PlaybackSourceSubtitleChoice {
|
|
final bool isOff;
|
|
final int? sourceStreamId;
|
|
|
|
const PlaybackSourceSubtitleChoice.off() : this._(isOff: true);
|
|
|
|
const PlaybackSourceSubtitleChoice.source(int sourceStreamId) : this._(isOff: false, sourceStreamId: sourceStreamId);
|
|
|
|
const PlaybackSourceSubtitleChoice._({required this.isOff, this.sourceStreamId});
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is PlaybackSourceSubtitleChoice && other.isOff == isOff && other.sourceStreamId == sourceStreamId;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(isOff, sourceStreamId);
|
|
}
|
|
|
|
/// Effective subtitle choice for one player open.
|
|
///
|
|
/// The source IDs remain stable across player reloads, while [primaryTrack]
|
|
/// and [secondaryTrack] carry the matching metadata used to select the newly
|
|
/// discovered native tracks after open.
|
|
class PlaybackSubtitleSelection {
|
|
final SubtitleTrack primaryTrack;
|
|
final int? primarySourceStreamId;
|
|
final PlaybackSubtitleSidecar? primarySidecar;
|
|
final SubtitleTrack? secondaryTrack;
|
|
final int? secondarySourceStreamId;
|
|
final PlaybackSubtitleSidecar? secondarySidecar;
|
|
final List<PlaybackSubtitleSidecar> preloadedSidecars;
|
|
|
|
/// The primary preference the resolver could not serve when this selection
|
|
/// is off. Distinguishes "the carried choice declined and the ladder fell
|
|
/// through" from a deliberate off (#1785): the open flow hands the declined
|
|
/// preference back to the track manager so late-arriving native tracks may
|
|
/// still serve it, and progress reports must not persist the fallout as an
|
|
/// explicit server-side off. A user or server decision leaves this null.
|
|
final SubtitlePreference? declinedPreference;
|
|
|
|
const PlaybackSubtitleSelection({
|
|
required this.primaryTrack,
|
|
this.primarySourceStreamId,
|
|
this.primarySidecar,
|
|
this.secondaryTrack,
|
|
this.secondarySourceStreamId,
|
|
this.secondarySidecar,
|
|
this.preloadedSidecars = const [],
|
|
this.declinedPreference,
|
|
});
|
|
|
|
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const [], this.declinedPreference})
|
|
: primaryTrack = SubtitleTrack.off,
|
|
primarySourceStreamId = null,
|
|
primarySidecar = null,
|
|
secondaryTrack = null,
|
|
secondarySourceStreamId = null,
|
|
secondarySidecar = null;
|
|
|
|
bool get isOff => primaryTrack.id == SubtitleTrack.off.id;
|
|
|
|
List<SubtitleTrack> get sidecarsAtOpen {
|
|
final tracks = <SubtitleTrack>[];
|
|
final added = <SubtitleTrack>{};
|
|
void add(SubtitleTrack? track) {
|
|
if (track != null && added.add(track)) tracks.add(track);
|
|
}
|
|
|
|
for (final sidecar in preloadedSidecars) {
|
|
add(sidecar.track);
|
|
}
|
|
add(primarySidecar?.track);
|
|
add(secondarySidecar?.track);
|
|
return tracks;
|
|
}
|
|
}
|
|
|
|
/// Resolves the server subtitle catalog before opening the native player,
|
|
/// combining the active choice with any sidecars marked for preloading.
|
|
class PlaybackSubtitleResolver {
|
|
const PlaybackSubtitleResolver._();
|
|
|
|
static SubtitlePreference? _sourceBackedPreference(
|
|
SubtitlePreference? preferred,
|
|
MediaSourceInfo? mediaInfo,
|
|
List<_SubtitleCandidate> candidates, {
|
|
required bool preserveSourceIdentity,
|
|
}) {
|
|
SubtitlePreference resolveIntent(SubtitleIntentPreference preference) {
|
|
final row = findSourceTrackForIntent(
|
|
preference.intent,
|
|
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
|
);
|
|
if (row != null) {
|
|
for (final candidate in candidates) {
|
|
if (candidate.sourceStreamId == row.id) return SubtitlePreference.track(candidate.track);
|
|
}
|
|
}
|
|
// Keep the declined intent: selectSubtitleTrack's intent branch and the
|
|
// priority ladder decide (falling to the server's own selection).
|
|
return preference;
|
|
}
|
|
|
|
// Crossing an item/source boundary strips identity from every reference.
|
|
final pref = preserveSourceIdentity ? preferred : SubtitlePreference.demoteToIntent(preferred);
|
|
switch (pref) {
|
|
case null || SubtitleOffPreference():
|
|
return pref;
|
|
case SubtitleIntentPreference():
|
|
return resolveIntent(pref);
|
|
case SubtitleTrackPreference(:final track):
|
|
if (track.id.startsWith('source:')) {
|
|
final sourceStreamId = int.tryParse(track.id.substring('source:'.length));
|
|
final exactCandidate = candidates
|
|
.where((candidate) => candidate.sourceStreamId == sourceStreamId)
|
|
.firstOrNull;
|
|
if (exactCandidate != null) return SubtitlePreference.track(exactCandidate.track);
|
|
// The row vanished from this source: the id is stale, so only its
|
|
// semantics may carry forward (hard-gated, unlike the old fuzzy
|
|
// rematch — a class-crossing lookalike must not inherit the id).
|
|
final demoted = SubtitlePreference.demoteToIntent(pref);
|
|
return demoted is SubtitleIntentPreference ? resolveIntent(demoted) : demoted;
|
|
}
|
|
// Same-item raw native/uri reference: identity-match it back to this
|
|
// item's own rows so the selection is source-backed where possible.
|
|
final sourceMatch = findPlexTrackForMpvSubtitle(
|
|
track,
|
|
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
|
);
|
|
if (sourceMatch != null) {
|
|
for (final candidate in candidates) {
|
|
if (candidate.sourceStreamId == sourceMatch.id) return SubtitlePreference.track(candidate.track);
|
|
}
|
|
}
|
|
return pref;
|
|
}
|
|
}
|
|
|
|
static PlaybackSubtitleSelection resolve({
|
|
required MediaItem metadata,
|
|
required MediaSourceInfo? mediaInfo,
|
|
required List<PlaybackSubtitleSidecar> sidecars,
|
|
MediaServerUserProfile? profileSettings,
|
|
AudioTrack? preferredAudioTrack,
|
|
SubtitlePreference? preferredSubtitleTrack,
|
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
|
bool preserveSourceIdentity = true,
|
|
}) {
|
|
final candidates = <_SubtitleCandidate>[];
|
|
final matchedSidecars = <PlaybackSubtitleSidecar>{};
|
|
|
|
for (final sourceTrack in mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[]) {
|
|
final sidecar = sidecars.where((candidate) => candidate.sourceStreamId == sourceTrack.id).firstOrNull;
|
|
if (sidecar != null) matchedSidecars.add(sidecar);
|
|
candidates.add(
|
|
_SubtitleCandidate(
|
|
track: subtitleTrackForSource(sourceTrack, sidecar: sidecar),
|
|
sourceStreamId: sourceTrack.id,
|
|
sidecar: sidecar,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Legacy/offline sidecars may not have cached source metadata. They still
|
|
// participate in normal default/profile selection and remain playable.
|
|
for (final sidecar in sidecars) {
|
|
if (matchedSidecars.contains(sidecar)) continue;
|
|
candidates.add(
|
|
_SubtitleCandidate(track: sidecar.track, sourceStreamId: sidecar.sourceStreamId, sidecar: sidecar),
|
|
);
|
|
}
|
|
|
|
final preloadedSidecars = sidecars.where((sidecar) => sidecar.preload).toList(growable: false);
|
|
final availableTracks = candidates.map((candidate) => candidate.track).toList(growable: false);
|
|
final service = TrackSelectionService(
|
|
profileSettings: profileSettings,
|
|
metadata: metadata,
|
|
plexMediaInfo: mediaInfo,
|
|
);
|
|
final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track;
|
|
final primaryPreference = _sourceBackedPreference(
|
|
preferredSubtitleTrack,
|
|
mediaInfo,
|
|
candidates,
|
|
preserveSourceIdentity: preserveSourceIdentity,
|
|
);
|
|
final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio);
|
|
final primary = primaryResult?.track;
|
|
// A non-off preference that still lands on off (or resolves to a track
|
|
// this catalog cannot back) was declined, not chosen — keep it on the
|
|
// selection so the open flow can retry it against native tracks (#1785).
|
|
final declinedPreference = primaryPreference != null && primaryPreference is! SubtitleOffPreference
|
|
? primaryPreference
|
|
: null;
|
|
if (primary == null || primary.id == SubtitleTrack.off.id) {
|
|
return PlaybackSubtitleSelection.off(
|
|
preloadedSidecars: preloadedSidecars,
|
|
declinedPreference: declinedPreference,
|
|
);
|
|
}
|
|
|
|
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
|
|
if (primaryCandidate == null) {
|
|
return PlaybackSubtitleSelection.off(
|
|
preloadedSidecars: preloadedSidecars,
|
|
declinedPreference: declinedPreference,
|
|
);
|
|
}
|
|
|
|
_SubtitleCandidate? secondaryCandidate;
|
|
final secondaryPreference = _sourceBackedPreference(
|
|
preferredSecondarySubtitleTrack,
|
|
mediaInfo,
|
|
candidates,
|
|
preserveSourceIdentity: preserveSourceIdentity,
|
|
);
|
|
if (secondaryPreference != null && secondaryPreference is! SubtitleOffPreference) {
|
|
final secondary = switch (secondaryPreference) {
|
|
SubtitleOffPreference() => null,
|
|
SubtitleTrackPreference(:final track) => service.findBestSubtitleMatch(availableTracks, track),
|
|
SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, availableTracks),
|
|
};
|
|
secondaryCandidate = candidates
|
|
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
|
.firstOrNull;
|
|
}
|
|
|
|
return PlaybackSubtitleSelection(
|
|
primaryTrack: primaryCandidate.track,
|
|
primarySourceStreamId: primaryCandidate.sourceStreamId,
|
|
primarySidecar: primaryCandidate.sidecar,
|
|
secondaryTrack: secondaryCandidate?.track,
|
|
secondarySourceStreamId: secondaryCandidate?.sourceStreamId,
|
|
secondarySidecar: secondaryCandidate?.sidecar,
|
|
preloadedSidecars: preloadedSidecars,
|
|
);
|
|
}
|
|
|
|
/// Stable source descriptor used for an explicit user selection. Supplying
|
|
/// this as the next open's preferred track makes it the highest-priority
|
|
/// choice without retaining a stale sidecar URL.
|
|
static SubtitleTrack? preferredTrackForSource(MediaSourceInfo? mediaInfo, int sourceStreamId) {
|
|
final sourceTrack = mediaInfo?.subtitleTracks.where((track) => track.id == sourceStreamId).firstOrNull;
|
|
return sourceTrack == null ? null : subtitleTrackForSource(sourceTrack);
|
|
}
|
|
|
|
static SubtitleTrack subtitleTrackForSource(MediaSubtitleTrack sourceTrack, {PlaybackSubtitleSidecar? sidecar}) {
|
|
final playable = sidecar?.track;
|
|
return SubtitleTrack(
|
|
id: 'source:${sourceTrack.id}',
|
|
// The row's own title first: server display titles collapse to the bare
|
|
// language ("English") and are identical across same-language rows, so
|
|
// a carried intent built from them cannot tell a Signs/Songs track from
|
|
// the full dialogue track on the next episode (#1785).
|
|
title: sourceTrack.title ?? playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.language,
|
|
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
|
|
codec: playable?.codec ?? sourceTrack.codec,
|
|
isDefault: sourceTrack.selected,
|
|
// Effective forced-ness: an intent captured from this committed track
|
|
// must stay in the same class as the row it came from (#1716).
|
|
isForced: sourceTrack.effectiveForced,
|
|
isExternal: playable != null,
|
|
isContainer: playable?.isContainer ?? false,
|
|
uri: playable?.uri,
|
|
);
|
|
}
|
|
|
|
/// Resolve a server source row to a track already loaded by the player.
|
|
/// Standalone sidecars match only by their stable URL key (or current source
|
|
/// identity), while a container sidecar uses normal Plex/native metadata
|
|
/// matching across the subtitle tracks extracted from that container.
|
|
static SubtitleTrack? nativeTrackForSource({
|
|
required MediaSubtitleTrack sourceTrack,
|
|
required List<SubtitleTrack> nativeTracks,
|
|
required List<MediaSubtitleTrack> allSourceTracks,
|
|
required bool isResolvedSidecar,
|
|
required bool isContainerSidecar,
|
|
int? currentSourceStreamId,
|
|
SubtitleTrack? selectedNativeTrack,
|
|
}) {
|
|
if (isResolvedSidecar) {
|
|
if (isContainerSidecar) {
|
|
final containerTracks = nativeTracks.where((track) => track.isContainer).toList(growable: false);
|
|
return findMpvTrackForPlexSubtitle(sourceTrack, containerTracks, allPlexTracks: allSourceTracks);
|
|
}
|
|
final key = sourceTrack.key;
|
|
if (key != null && key.isNotEmpty) {
|
|
for (final candidate in nativeTracks) {
|
|
if (candidate.isExternal && candidate.uri?.contains(key) == true) return candidate;
|
|
}
|
|
}
|
|
if (currentSourceStreamId == sourceTrack.id &&
|
|
selectedNativeTrack != null &&
|
|
selectedNativeTrack.id != SubtitleTrack.off.id &&
|
|
selectedNativeTrack.isExternal) {
|
|
return selectedNativeTrack;
|
|
}
|
|
return null;
|
|
}
|
|
return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks);
|
|
}
|
|
|
|
static PlaybackSourceSubtitleChoice advanceSourceChoice(
|
|
List<MediaSubtitleTrack> tracks,
|
|
PlaybackSourceSubtitleChoice currentChoice,
|
|
int advances,
|
|
) {
|
|
final choices = <PlaybackSourceSubtitleChoice>[
|
|
const PlaybackSourceSubtitleChoice.off(),
|
|
...tracks.map((track) => PlaybackSourceSubtitleChoice.source(track.id)),
|
|
];
|
|
final currentIndex = choices.indexOf(currentChoice);
|
|
final normalizedCurrentIndex = currentIndex < 0 ? 0 : currentIndex;
|
|
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>[]) audioTrackForSource(track)];
|
|
}
|
|
}
|
|
|
|
class _SubtitleCandidate {
|
|
final SubtitleTrack track;
|
|
final int? sourceStreamId;
|
|
final PlaybackSubtitleSidecar? sidecar;
|
|
|
|
const _SubtitleCandidate({required this.track, required this.sourceStreamId, required this.sidecar});
|
|
}
|