The cross-item subtitle intent required declared languages on both sides, and a null on either side counted as a contradiction. Any untagged track - common when a title like "Swedish" is the only signal - declined on every episode advance, fell to the server's per-item priority, and turned the viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard gates). A unique real title match now vouches for a row when language evidence is missing on either side. Declared languages that disagree still decline no matter what the title says, forced-class parity is untouched, codec and external parity only break ties within the title-matched set, and a residual tie declines rather than guesses, so the wrong-track class of #1716 stays closed. A decline is also no longer laundered into a viewer decision: the resolver keeps the unserved preference on the selection, the open flow hands it to the track manager instead of a navigation-priority off (late native tracks may carry the container tags the server rows lack), the next episode boundary re-carries it instead of hardening it into an explicit off, and progress reports withhold the -1 subtitle index that would otherwise come back as the item's server-side default forever. A pick the screen could not map to a source row (no subtitle catalog, or an identity-matcher miss) previously never reached the committed session selection at all, so the next episode carried the stale off while the picked track was visibly on screen. Such picks now commit the raw native track without source ids and demote to a semantic intent at the boundary. close #1785
350 lines
14 KiB
Dart
350 lines
14 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}',
|
|
title: playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.title ?? 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];
|
|
}
|
|
|
|
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
|
|
return [
|
|
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
|
|
AudioTrack(
|
|
id: 'source:${track.id}',
|
|
title: track.displayTitle ?? track.title ?? track.language,
|
|
language: track.languageCode ?? track.language,
|
|
codec: track.codec,
|
|
channels: track.channels,
|
|
isDefault: track.selected,
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
class _SubtitleCandidate {
|
|
final SubtitleTrack track;
|
|
final int? sourceStreamId;
|
|
final PlaybackSubtitleSidecar? sidecar;
|
|
|
|
const _SubtitleCandidate({required this.track, required this.sourceStreamId, required this.sidecar});
|
|
}
|