Files
plezy/lib/services/subtitle_preference.dart
T
edde746 daab4f1e24 fix(player): preserve the forced-subtitle class across episode boundaries
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
2026-07-30 02:46:10 +02:00

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)';
}