Plex treats a subtitle stream as forced when its title says "Forced" even with the API flag unset. Every forced comparison now uses that effective forced-ness on both sides: the match scorer, the low-metadata hard gate, the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation. Carrying a track choice into the next episode no longer reuses the same-item identity matchers. A sealed SubtitlePreference (off / track reference / semantic intent) replaces the id-'navigation' pseudo-track through the whole preference channel, and cross-item intents hard-require language and forced-class parity. When the next episode has no track of the same class, the intent declines and selection falls through to the server's own per-episode choice instead of latching onto a full track by position and persisting that mistake back to the server. Intents wait for pending native tracks under the same catalog-completeness rule as source ids, so an early decline cannot retire the selection listener before the real track arrives. Ref #1716
132 lines
4.6 KiB
Dart
132 lines
4.6 KiB
Dart
import '../mpv/mpv.dart';
|
|
import '../utils/subtitle_forced_semantics.dart';
|
|
|
|
/// Item-agnostic semantic subtitle intent.
|
|
///
|
|
/// [language] and [forced] are hard matching requirements — an intent only
|
|
/// resolves to a track of the same language AND the same forced-ness class,
|
|
/// otherwise resolution declines and the selection ladder falls back to the
|
|
/// server's own per-item choice (#1716/#1717). [forced] is EFFECTIVE
|
|
/// forced-ness (flag OR title-says-forced), captured once at the boundary.
|
|
final class SubtitleIntent {
|
|
final String? language;
|
|
final bool forced;
|
|
|
|
/// Tiebreakers between same-class candidates, and log diagnostics.
|
|
final String? title;
|
|
final String? codec;
|
|
final bool isExternal;
|
|
|
|
const SubtitleIntent({this.language, required this.forced, this.title, this.codec, this.isExternal = false});
|
|
|
|
/// Null when [track] is null/off or carries no semantic metadata at all —
|
|
/// an intent that can never resolve is not worth carrying.
|
|
static SubtitleIntent? fromTrack(SubtitleTrack? track) {
|
|
if (track == null || track.id == SubtitleTrack.off.id) return null;
|
|
final hasSemanticMetadata =
|
|
(track.title?.isNotEmpty ?? false) ||
|
|
(track.language?.isNotEmpty ?? false) ||
|
|
(track.codec?.isNotEmpty ?? false);
|
|
if (!hasSemanticMetadata) return null;
|
|
return SubtitleIntent(
|
|
language: track.language,
|
|
forced: track.effectiveForced,
|
|
title: track.title,
|
|
codec: track.codec,
|
|
isExternal: track.isExternal,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is SubtitleIntent &&
|
|
other.language == language &&
|
|
other.forced == forced &&
|
|
other.title == title &&
|
|
other.codec == codec &&
|
|
other.isExternal == isExternal;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(language, forced, title, codec, isExternal);
|
|
|
|
@override
|
|
String toString() => 'SubtitleIntent($language, forced: $forced, title: $title, codec: $codec)';
|
|
}
|
|
|
|
/// What the next open/selection pass should aim for, subtitle-wise.
|
|
///
|
|
/// [SubtitlePreference.track] is an identity reference (`source:` row, sidecar
|
|
/// URI, or raw native track) valid only within the current item and media
|
|
/// source. [SubtitlePreference.intent] carries semantics alone and is the only
|
|
/// form that may cross an item/source boundary — see [demoteToIntent].
|
|
sealed class SubtitlePreference {
|
|
const SubtitlePreference();
|
|
|
|
const factory SubtitlePreference.off() = SubtitleOffPreference;
|
|
const factory SubtitlePreference.track(SubtitleTrack track) = SubtitleTrackPreference;
|
|
const factory SubtitlePreference.intent(SubtitleIntent intent) = SubtitleIntentPreference;
|
|
|
|
/// Identity-ref wrapper: null stays null, the off sentinel becomes [off].
|
|
static SubtitlePreference? trackOrNull(SubtitleTrack? track) {
|
|
if (track == null) return null;
|
|
if (track.id == SubtitleTrack.off.id) return const SubtitlePreference.off();
|
|
return SubtitlePreference.track(track);
|
|
}
|
|
|
|
/// Crossing an item/source boundary: identity references lose their meaning
|
|
/// there, so every track reference becomes a semantic intent (or null when
|
|
/// it has no semantics). Off and existing intents pass through.
|
|
static SubtitlePreference? demoteToIntent(SubtitlePreference? preference) {
|
|
switch (preference) {
|
|
case null || SubtitleOffPreference() || SubtitleIntentPreference():
|
|
return preference;
|
|
case SubtitleTrackPreference(:final track):
|
|
final intent = SubtitleIntent.fromTrack(track);
|
|
return intent == null ? null : SubtitlePreference.intent(intent);
|
|
}
|
|
}
|
|
}
|
|
|
|
final class SubtitleOffPreference extends SubtitlePreference {
|
|
const SubtitleOffPreference();
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is SubtitleOffPreference;
|
|
|
|
@override
|
|
int get hashCode => (SubtitleOffPreference).hashCode;
|
|
|
|
@override
|
|
String toString() => 'SubtitlePreference.off';
|
|
}
|
|
|
|
final class SubtitleTrackPreference extends SubtitlePreference {
|
|
final SubtitleTrack track;
|
|
|
|
const SubtitleTrackPreference(this.track);
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is SubtitleTrackPreference && other.track == track;
|
|
|
|
@override
|
|
int get hashCode => track.hashCode;
|
|
|
|
@override
|
|
String toString() => 'SubtitlePreference.track(${track.id})';
|
|
}
|
|
|
|
final class SubtitleIntentPreference extends SubtitlePreference {
|
|
final SubtitleIntent intent;
|
|
|
|
const SubtitleIntentPreference(this.intent);
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is SubtitleIntentPreference && other.intent == intent;
|
|
|
|
@override
|
|
int get hashCode => intent.hashCode;
|
|
|
|
@override
|
|
String toString() => 'SubtitlePreference.intent($intent)';
|
|
}
|