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
This commit is contained in:
@@ -14,7 +14,7 @@ Future<bool> deferTranscodeSubtitleSelection({
|
|||||||
required bool Function() shouldContinue,
|
required bool Function() shouldContinue,
|
||||||
}) async {
|
}) async {
|
||||||
final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar);
|
final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar);
|
||||||
trackManager.preferredSubtitleTrack = deferredTrack;
|
trackManager.preferredSubtitleTrack = SubtitlePreference.track(deferredTrack);
|
||||||
// Persist first: the screen callback routes to onSubtitleTrackSelectedByUser,
|
// Persist first: the screen callback routes to onSubtitleTrackSelectedByUser,
|
||||||
// which invalidates the pending selection. Arming before that would retire the
|
// which invalidates the pending selection. Arming before that would retire the
|
||||||
// deferred pass we depend on to apply this choice once mpv discovers the sidecar.
|
// deferred pass we depend on to apply this choice once mpv discovers the sidecar.
|
||||||
@@ -248,10 +248,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
|
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
|
||||||
final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls());
|
final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls());
|
||||||
final preferredSubtitleTrackForReload = newSubtitleChoice == null
|
final preferredSubtitleTrackForReload = newSubtitleChoice == null
|
||||||
? _playbackSession?.subtitleSelection.primaryTrack
|
? SubtitlePreference.trackOrNull(_playbackSession?.subtitleSelection.primaryTrack)
|
||||||
: newSubtitleChoice.isOff
|
: newSubtitleChoice.isOff
|
||||||
? SubtitleTrack.off
|
? const SubtitlePreference.off()
|
||||||
: PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!);
|
: SubtitlePreference.trackOrNull(
|
||||||
|
PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!),
|
||||||
|
);
|
||||||
final effectiveMediaSourceId = newMediaIndex != null
|
final effectiveMediaSourceId = newMediaIndex != null
|
||||||
? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId
|
? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId
|
||||||
: _requestedMediaSourceId;
|
: _requestedMediaSourceId;
|
||||||
@@ -417,9 +419,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
Duration? resumePosition,
|
Duration? resumePosition,
|
||||||
bool preserveCurrentTrackSelection = false,
|
bool preserveCurrentTrackSelection = false,
|
||||||
AudioTrack? preservedAudioTrack,
|
AudioTrack? preservedAudioTrack,
|
||||||
SubtitleTrack? preservedSubtitleTrack,
|
SubtitlePreference? preservedSubtitleTrack,
|
||||||
SubtitleTrack? preservedSecondarySubtitleTrack,
|
SubtitlePreference? preservedSecondarySubtitleTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrackOverride,
|
SubtitlePreference? preferredSubtitleTrackOverride,
|
||||||
bool startPaused = false,
|
bool startPaused = false,
|
||||||
bool useCurrentAudioStreamSelection = true,
|
bool useCurrentAudioStreamSelection = true,
|
||||||
bool showErrorUi = true,
|
bool showErrorUi = true,
|
||||||
@@ -473,9 +475,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
: null;
|
: null;
|
||||||
final currentSubtitleTrack =
|
final currentSubtitleTrack =
|
||||||
preferredSubtitleTrackOverride ??
|
preferredSubtitleTrackOverride ??
|
||||||
(preserveCurrentTrackSelection ? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle : null);
|
(preserveCurrentTrackSelection
|
||||||
|
? preservedSubtitleTrack ?? SubtitlePreference.trackOrNull(currentPlayer.state.track.subtitle)
|
||||||
|
: null);
|
||||||
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
|
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
|
||||||
? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle
|
? preservedSecondarySubtitleTrack ??
|
||||||
|
SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle)
|
||||||
: null;
|
: null;
|
||||||
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
|
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
|
||||||
var didOpenReplacement = false;
|
var didOpenReplacement = false;
|
||||||
@@ -545,7 +550,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
(selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId);
|
(selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId);
|
||||||
final initializationSubtitleTrack = preservesRequestedSubtitleSource
|
final initializationSubtitleTrack = preservesRequestedSubtitleSource
|
||||||
? currentSubtitleTrack
|
? currentSubtitleTrack
|
||||||
: PlaybackSubtitleResolver.preferenceWithoutSourceIdentity(currentSubtitleTrack);
|
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
|
||||||
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
|
||||||
@@ -757,8 +762,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
plexClient: plexClient,
|
plexClient: plexClient,
|
||||||
getProfileSettings: () => userProfileProvider.profileSettings,
|
getProfileSettings: () => userProfileProvider.profileSettings,
|
||||||
preferredAudioTrack: currentAudioTrack,
|
preferredAudioTrack: currentAudioTrack,
|
||||||
preferredSubtitleTrack: subtitleSelection.primaryTrack,
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||||
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||||
);
|
);
|
||||||
_trackManager = trackManager;
|
_trackManager = trackManager;
|
||||||
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
||||||
|
|||||||
@@ -319,8 +319,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
|||||||
resumePosition: resumePosition,
|
resumePosition: resumePosition,
|
||||||
preserveCurrentTrackSelection: true,
|
preserveCurrentTrackSelection: true,
|
||||||
preservedAudioTrack: audioTrack,
|
preservedAudioTrack: audioTrack,
|
||||||
preservedSubtitleTrack: subtitleTrack,
|
preservedSubtitleTrack: SubtitlePreference.trackOrNull(subtitleTrack),
|
||||||
preservedSecondarySubtitleTrack: secondarySubtitleTrack,
|
preservedSecondarySubtitleTrack: SubtitlePreference.trackOrNull(secondarySubtitleTrack),
|
||||||
startPaused: true,
|
startPaused: true,
|
||||||
reason: 'TV background suspend restore',
|
reason: 'TV background suspend restore',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
required MediaItem metadata,
|
required MediaItem metadata,
|
||||||
required PlaybackInitializationResult result,
|
required PlaybackInitializationResult result,
|
||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
bool preserveSubtitleSourceIdentity = true,
|
bool preserveSubtitleSourceIdentity = true,
|
||||||
}) async {
|
}) async {
|
||||||
await _waitForProfileSettingsIfNeeded();
|
await _waitForProfileSettingsIfNeeded();
|
||||||
@@ -440,8 +440,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
required PlexClient? plexClient,
|
required PlexClient? plexClient,
|
||||||
required MediaServerUserProfile? Function() getProfileSettings,
|
required MediaServerUserProfile? Function() getProfileSettings,
|
||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
}) {
|
}) {
|
||||||
return TrackManager(
|
return TrackManager(
|
||||||
player: forPlayer,
|
player: forPlayer,
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
plexClient: mediaClient is PlexClient ? mediaClient : null,
|
plexClient: mediaClient is PlexClient ? mediaClient : null,
|
||||||
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
||||||
preferredAudioTrack: _preferredAudioTrack,
|
preferredAudioTrack: _preferredAudioTrack,
|
||||||
preferredSubtitleTrack: subtitleSelection.primaryTrack,
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||||
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store only the active sidecars for re-use after backend fallback.
|
// Store only the active sidecars for re-use after backend fallback.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import '../services/display_mode_service.dart';
|
|||||||
import '../services/media_control_router.dart';
|
import '../services/media_control_router.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/sleep_timer_service.dart';
|
import '../services/sleep_timer_service.dart';
|
||||||
|
import '../services/subtitle_preference.dart';
|
||||||
import '../services/track_manager.dart';
|
import '../services/track_manager.dart';
|
||||||
import '../services/track_selection_service.dart';
|
import '../services/track_selection_service.dart';
|
||||||
import '../services/ambient_lighting_service.dart';
|
import '../services/ambient_lighting_service.dart';
|
||||||
@@ -137,39 +138,27 @@ bool shouldAutoStartReloadedMedia({
|
|||||||
/// Builds an item-agnostic subtitle preference for an episode replacement.
|
/// Builds an item-agnostic subtitle preference for an episode replacement.
|
||||||
///
|
///
|
||||||
/// Source ids and sidecar URIs belong to the current media item. Only the
|
/// Source ids and sidecar URIs belong to the current media item. Only the
|
||||||
/// committed semantic choice may cross the item boundary; native state is a
|
/// committed semantic choice — a [SubtitleIntent] — may cross the item
|
||||||
/// fallback for sessions created before source-backed selection was recorded.
|
/// boundary; native state is a fallback for sessions created before
|
||||||
SubtitleTrack? subtitlePreferenceForItemChange({
|
/// source-backed selection was recorded.
|
||||||
|
SubtitlePreference? subtitlePreferenceForItemChange({
|
||||||
required bool hasCommittedSelection,
|
required bool hasCommittedSelection,
|
||||||
required SubtitleTrack? committedTrack,
|
required SubtitleTrack? committedTrack,
|
||||||
required SubtitleTrack? nativeTrack,
|
required SubtitleTrack? nativeTrack,
|
||||||
}) {
|
}) {
|
||||||
SubtitleTrack? 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 ? SubtitleTrack.off : null;
|
if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.off() : null;
|
||||||
|
|
||||||
final hasSemanticMetadata =
|
final intent = SubtitleIntent.fromTrack(track);
|
||||||
(track.title?.isNotEmpty ?? false) ||
|
return intent == null ? null : SubtitlePreference.intent(intent);
|
||||||
(track.language?.isNotEmpty ?? false) ||
|
|
||||||
(track.codec?.isNotEmpty ?? false);
|
|
||||||
if (!hasSemanticMetadata) return null;
|
|
||||||
|
|
||||||
return SubtitleTrack(
|
|
||||||
id: 'navigation',
|
|
||||||
title: track.title,
|
|
||||||
language: track.language,
|
|
||||||
codec: track.codec,
|
|
||||||
isDefault: track.isDefault,
|
|
||||||
isForced: track.isForced,
|
|
||||||
isExternal: track.isExternal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCommittedSelection) {
|
if (!hasCommittedSelection) {
|
||||||
return normalize(nativeTrack, preserveOff: true);
|
return normalize(nativeTrack, preserveOff: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (committedTrack == null) return SubtitleTrack.off;
|
if (committedTrack == null) return const SubtitlePreference.off();
|
||||||
|
|
||||||
final committedPreference = normalize(committedTrack, preserveOff: true);
|
final committedPreference = normalize(committedTrack, preserveOff: true);
|
||||||
if (committedPreference != null) return committedPreference;
|
if (committedPreference != null) return committedPreference;
|
||||||
@@ -377,8 +366,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
late TranscodeQualityPreset _selectedQualityPreset;
|
late TranscodeQualityPreset _selectedQualityPreset;
|
||||||
int? _selectedAudioStreamId;
|
int? _selectedAudioStreamId;
|
||||||
AudioTrack? _preferredAudioTrack;
|
AudioTrack? _preferredAudioTrack;
|
||||||
SubtitleTrack? _preferredSubtitleTrack;
|
SubtitlePreference? _preferredSubtitleTrack;
|
||||||
SubtitleTrack? _preferredSecondarySubtitleTrack;
|
SubtitlePreference? _preferredSecondarySubtitleTrack;
|
||||||
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)
|
||||||
@@ -788,8 +777,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_playbackTranscodeSessionId = generateSessionIdentifier();
|
_playbackTranscodeSessionId = generateSessionIdentifier();
|
||||||
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
||||||
_preferredAudioTrack = widget.preferredAudioTrack;
|
_preferredAudioTrack = widget.preferredAudioTrack;
|
||||||
_preferredSubtitleTrack = widget.preferredSubtitleTrack;
|
_preferredSubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSubtitleTrack);
|
||||||
_preferredSecondarySubtitleTrack = widget.preferredSecondarySubtitleTrack;
|
_preferredSecondarySubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSecondarySubtitleTrack);
|
||||||
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
||||||
|
|
||||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||||
@@ -811,10 +800,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_preferredSubtitleTrack != null) {
|
if (_preferredSubtitleTrack != null) {
|
||||||
final subtitleDesc = _preferredSubtitleTrack!.id == "no"
|
appLogger.d('Preferred subtitle track: $_preferredSubtitleTrack');
|
||||||
? "OFF"
|
|
||||||
: "${_preferredSubtitleTrack!.title ?? _preferredSubtitleTrack!.id} (${_preferredSubtitleTrack!.language ?? "unknown"})";
|
|
||||||
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import 'jellyfin_playback_urls.dart';
|
|||||||
import 'jellyfin_trickplay_service.dart';
|
import 'jellyfin_trickplay_service.dart';
|
||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
import 'scrub_preview_source.dart';
|
import 'scrub_preview_source.dart';
|
||||||
|
import 'subtitle_preference.dart';
|
||||||
import 'track_selection_service.dart';
|
import 'track_selection_service.dart';
|
||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -298,41 +298,31 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
|
return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) {
|
int? _validJellyfinSubtitleStreamId(SubtitlePreference? preferred, MediaSourceInfo mediaInfo) {
|
||||||
if (preferred == null) return null;
|
switch (preferred) {
|
||||||
if (preferred.id == SubtitleTrack.off.id) return -1;
|
case null:
|
||||||
|
return null;
|
||||||
var semanticPreference = preferred;
|
case SubtitleOffPreference():
|
||||||
const sourcePrefix = 'source:';
|
return -1;
|
||||||
if (preferred.id.startsWith(sourcePrefix)) {
|
case SubtitleIntentPreference(:final intent):
|
||||||
semanticPreference = SubtitleTrack(
|
return findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id;
|
||||||
id: 'navigation',
|
case SubtitleTrackPreference(:final track):
|
||||||
title: preferred.title,
|
const sourcePrefix = 'source:';
|
||||||
language: preferred.language,
|
final intent = SubtitleIntent.fromTrack(track);
|
||||||
codec: preferred.codec,
|
if (track.id.startsWith(sourcePrefix)) {
|
||||||
isDefault: preferred.isDefault,
|
final explicit = int.tryParse(track.id.substring(sourcePrefix.length));
|
||||||
isForced: preferred.isForced,
|
if (explicit != null && mediaInfo.subtitleTracks.any((row) => row.id == explicit)) {
|
||||||
isExternal: preferred.isExternal,
|
// A source id is authoritative only within one item. When semantic
|
||||||
isContainer: preferred.isContainer,
|
// metadata is available, re-derive the row through the hard-gated
|
||||||
);
|
// intent match so a reused stream index cannot cross language or
|
||||||
final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length));
|
// forced-ness classes (#1716).
|
||||||
if (explicit != null) {
|
final hasLanguage = intent?.language?.isNotEmpty ?? false;
|
||||||
final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull;
|
if (!hasLanguage) return explicit;
|
||||||
if (exactSource != null) {
|
return findSourceTrackForIntent(intent!, mediaInfo.subtitleTracks)?.id;
|
||||||
// A source id is authoritative only within one item. When semantic
|
|
||||||
// metadata is available, prefer the best current-source row so a
|
|
||||||
// reused stream index cannot override a better title/codec match.
|
|
||||||
final semanticMatch = findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks);
|
|
||||||
final hasLanguage = preferred.language?.isNotEmpty ?? false;
|
|
||||||
if (!hasLanguage || (semanticMatch?.id == explicit && preferred.isForced == exactSource.forced)) {
|
|
||||||
return explicit;
|
|
||||||
}
|
}
|
||||||
return semanticMatch?.id;
|
|
||||||
}
|
}
|
||||||
}
|
return intent == null ? null : findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks)?.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../media/media_version.dart';
|
|||||||
import '../models/audio_quality_preset.dart';
|
import '../models/audio_quality_preset.dart';
|
||||||
import '../models/transcode_quality_preset.dart';
|
import '../models/transcode_quality_preset.dart';
|
||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
import 'subtitle_preference.dart';
|
||||||
|
|
||||||
/// Inputs for [MediaServerClient.getPlaybackInitialization]. Most fields
|
/// Inputs for [MediaServerClient.getPlaybackInitialization]. Most fields
|
||||||
/// are backend-specific knobs (transcode preset, audio stream, session ids).
|
/// are backend-specific knobs (transcode preset, audio stream, session ids).
|
||||||
@@ -44,7 +45,7 @@ class PlaybackInitializationOptions {
|
|||||||
/// 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.
|
||||||
final SubtitleTrack? preferredSubtitleTrack;
|
final SubtitlePreference? preferredSubtitleTrack;
|
||||||
|
|
||||||
/// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode.
|
/// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode.
|
||||||
final String? sessionIdentifier;
|
final String? sessionIdentifier;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import '../media/media_item.dart';
|
|||||||
import '../media/media_server_user_profile.dart';
|
import '../media/media_server_user_profile.dart';
|
||||||
import '../media/media_source_info.dart';
|
import '../media/media_source_info.dart';
|
||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
import '../utils/subtitle_forced_semantics.dart';
|
||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
|
import 'subtitle_preference.dart';
|
||||||
import 'track_selection_service.dart';
|
import 'track_selection_service.dart';
|
||||||
|
|
||||||
/// A source-catalog subtitle choice.
|
/// A source-catalog subtitle choice.
|
||||||
@@ -84,53 +86,57 @@ class PlaybackSubtitleSelection {
|
|||||||
class PlaybackSubtitleResolver {
|
class PlaybackSubtitleResolver {
|
||||||
const PlaybackSubtitleResolver._();
|
const PlaybackSubtitleResolver._();
|
||||||
|
|
||||||
/// Removes a per-source ID while retaining the semantic identity needed to
|
static SubtitlePreference? _sourceBackedPreference(
|
||||||
/// carry a subtitle choice across items or media sources.
|
SubtitlePreference? preferred,
|
||||||
static SubtitleTrack? preferenceWithoutSourceIdentity(SubtitleTrack? preferred) {
|
|
||||||
if (preferred == null || preferred.id == SubtitleTrack.off.id || !preferred.id.startsWith('source:')) {
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
return SubtitleTrack(
|
|
||||||
id: 'navigation',
|
|
||||||
title: preferred.title,
|
|
||||||
language: preferred.language,
|
|
||||||
codec: preferred.codec,
|
|
||||||
isDefault: preferred.isDefault,
|
|
||||||
isForced: preferred.isForced,
|
|
||||||
isExternal: preferred.isExternal,
|
|
||||||
isContainer: preferred.isContainer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static SubtitleTrack? _sourceBackedPreference(
|
|
||||||
SubtitleTrack? preferred,
|
|
||||||
MediaSourceInfo? mediaInfo,
|
MediaSourceInfo? mediaInfo,
|
||||||
List<_SubtitleCandidate> candidates, {
|
List<_SubtitleCandidate> candidates, {
|
||||||
required bool preserveSourceIdentity,
|
required bool preserveSourceIdentity,
|
||||||
}) {
|
}) {
|
||||||
if (preferred == null || preferred.id == SubtitleTrack.off.id) return preferred;
|
SubtitlePreference resolveIntent(SubtitleIntentPreference preference) {
|
||||||
|
final row = findSourceTrackForIntent(preference.intent, mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[]);
|
||||||
var semanticPreference = preferred;
|
if (row != null) {
|
||||||
if (preferred.id.startsWith('source:')) {
|
for (final candidate in candidates) {
|
||||||
if (preserveSourceIdentity) {
|
if (candidate.sourceStreamId == row.id) return SubtitlePreference.track(candidate.track);
|
||||||
final sourceStreamId = int.tryParse(preferred.id.substring('source:'.length));
|
}
|
||||||
final exactCandidate = candidates.where((candidate) => candidate.sourceStreamId == sourceStreamId).firstOrNull;
|
|
||||||
if (exactCandidate != null) return exactCandidate.track;
|
|
||||||
} else {
|
|
||||||
semanticPreference = preferenceWithoutSourceIdentity(preferred)!;
|
|
||||||
}
|
}
|
||||||
|
// Keep the declined intent: selectSubtitleTrack's intent branch and the
|
||||||
|
// priority ladder decide (falling to the server's own selection).
|
||||||
|
return preference;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sourceMatch = findPlexTrackForMpvSubtitle(
|
// Crossing an item/source boundary strips identity from every reference.
|
||||||
semanticPreference,
|
final pref = preserveSourceIdentity ? preferred : SubtitlePreference.demoteToIntent(preferred);
|
||||||
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
switch (pref) {
|
||||||
);
|
case null || SubtitleOffPreference():
|
||||||
if (sourceMatch == null) return semanticPreference;
|
return pref;
|
||||||
|
case SubtitleIntentPreference():
|
||||||
for (final candidate in candidates) {
|
return resolveIntent(pref);
|
||||||
if (candidate.sourceStreamId == sourceMatch.id) return candidate.track;
|
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;
|
||||||
}
|
}
|
||||||
return semanticPreference;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static PlaybackSubtitleSelection resolve({
|
static PlaybackSubtitleSelection resolve({
|
||||||
@@ -139,8 +145,8 @@ class PlaybackSubtitleResolver {
|
|||||||
required List<PlaybackSubtitleSidecar> sidecars,
|
required List<PlaybackSubtitleSidecar> sidecars,
|
||||||
MediaServerUserProfile? profileSettings,
|
MediaServerUserProfile? profileSettings,
|
||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
bool preserveSourceIdentity = true,
|
bool preserveSourceIdentity = true,
|
||||||
}) {
|
}) {
|
||||||
final candidates = <_SubtitleCandidate>[];
|
final candidates = <_SubtitleCandidate>[];
|
||||||
@@ -199,8 +205,12 @@ class PlaybackSubtitleResolver {
|
|||||||
candidates,
|
candidates,
|
||||||
preserveSourceIdentity: preserveSourceIdentity,
|
preserveSourceIdentity: preserveSourceIdentity,
|
||||||
);
|
);
|
||||||
if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) {
|
if (secondaryPreference != null && secondaryPreference is! SubtitleOffPreference) {
|
||||||
final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference);
|
final secondary = switch (secondaryPreference) {
|
||||||
|
SubtitleOffPreference() => null,
|
||||||
|
SubtitleTrackPreference(:final track) => service.findBestSubtitleMatch(availableTracks, track),
|
||||||
|
SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, availableTracks),
|
||||||
|
};
|
||||||
secondaryCandidate = candidates
|
secondaryCandidate = candidates
|
||||||
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
@@ -233,7 +243,9 @@ class PlaybackSubtitleResolver {
|
|||||||
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
|
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
|
||||||
codec: playable?.codec ?? sourceTrack.codec,
|
codec: playable?.codec ?? sourceTrack.codec,
|
||||||
isDefault: sourceTrack.selected,
|
isDefault: sourceTrack.selected,
|
||||||
isForced: sourceTrack.forced,
|
// 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,
|
isExternal: playable != null,
|
||||||
isContainer: playable?.isContainer ?? false,
|
isContainer: playable?.isContainer ?? false,
|
||||||
uri: playable?.uri,
|
uri: playable?.uri,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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)';
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import '../media/media_item.dart';
|
|||||||
import '../media/media_server_user_profile.dart';
|
import '../media/media_server_user_profile.dart';
|
||||||
import '../media/media_source_info.dart';
|
import '../media/media_source_info.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/subtitle_preference.dart';
|
||||||
import '../services/track_selection_service.dart';
|
import '../services/track_selection_service.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/track_label_builder.dart';
|
import '../utils/track_label_builder.dart';
|
||||||
@@ -50,8 +51,8 @@ class TrackManager {
|
|||||||
MediaItem metadata;
|
MediaItem metadata;
|
||||||
MediaSourceInfo? mediaInfo;
|
MediaSourceInfo? mediaInfo;
|
||||||
AudioTrack? preferredAudioTrack;
|
AudioTrack? preferredAudioTrack;
|
||||||
SubtitleTrack? preferredSubtitleTrack;
|
SubtitlePreference? preferredSubtitleTrack;
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack;
|
SubtitlePreference? preferredSecondarySubtitleTrack;
|
||||||
|
|
||||||
// ── Internal state ─────────────────────────────────────────────────
|
// ── Internal state ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../media/media_source_info.dart';
|
|||||||
import '../utils/future_extensions.dart';
|
import '../utils/future_extensions.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/language_codes.dart';
|
import '../utils/language_codes.dart';
|
||||||
|
import '../utils/subtitle_forced_semantics.dart';
|
||||||
|
import 'subtitle_preference.dart';
|
||||||
|
|
||||||
// These functions match MPV tracks to Plex tracks by properties (language,
|
// These functions match MPV tracks to Plex tracks by properties (language,
|
||||||
// codec, title, etc.) instead of list index, since the two may be ordered
|
// codec, title, etc.) instead of list index, since the two may be ordered
|
||||||
@@ -34,7 +36,7 @@ int _scoreSubtitleMatch(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack, {r
|
|||||||
|
|
||||||
score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle);
|
score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle);
|
||||||
|
|
||||||
if (mpvTrack.isForced == plexTrack.forced) {
|
if (mpvTrack.effectiveForced == plexTrack.effectiveForced) {
|
||||||
score += 2;
|
score += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ bool _lowMetadataSubtitleFactsAreCompatible(SubtitleTrack mpvTrack, MediaSubtitl
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mpvTrack.isForced == plexTrack.forced;
|
return mpvTrack.effectiveForced == plexTrack.effectiveForced;
|
||||||
}
|
}
|
||||||
|
|
||||||
int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack) {
|
int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack) {
|
||||||
@@ -152,7 +154,7 @@ int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack pl
|
|||||||
_titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle) > 0) {
|
_titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle) > 0) {
|
||||||
score += 3;
|
score += 3;
|
||||||
}
|
}
|
||||||
if (mpvTrack.isForced == plexTrack.forced) score += 2;
|
if (mpvTrack.effectiveForced == plexTrack.effectiveForced) score += 2;
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +343,54 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the source-catalog row that serves a cross-item subtitle intent.
|
||||||
|
///
|
||||||
|
/// Identity matching ([findPlexTrackForMpvSubtitle]) answers "which row IS
|
||||||
|
/// this track"; this answers "which row of a DIFFERENT item serves the same
|
||||||
|
/// intent". Language and effective forced-ness are hard requirements: the
|
||||||
|
/// intent's class is preserved or the match declines, so the selection ladder
|
||||||
|
/// can fall back to the server's own per-item choice (#1716/#1717).
|
||||||
|
MediaSubtitleTrack? findSourceTrackForIntent(SubtitleIntent intent, List<MediaSubtitleTrack> sourceTracks) {
|
||||||
|
MediaSubtitleTrack? bestMatch;
|
||||||
|
var bestScore = -1;
|
||||||
|
for (final row in sourceTracks) {
|
||||||
|
if (!_languagesMatch(intent.language, row.languageCode ?? row.language)) continue;
|
||||||
|
if (row.effectiveForced != intent.forced) continue;
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
if (_subtitleCodecsMatch(intent.codec, row.codec)) score += 5;
|
||||||
|
score += _titleScore(intent.title, row.title, row.displayTitle);
|
||||||
|
if (intent.isExternal == row.isExternal) score += 1;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMatch = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native-track twin of [findSourceTrackForIntent], for catalogs the source
|
||||||
|
/// side cannot describe (legacy offline sidecars) and late-arriving tracks.
|
||||||
|
SubtitleTrack? findNativeTrackForIntent(SubtitleIntent intent, List<SubtitleTrack> tracks) {
|
||||||
|
SubtitleTrack? bestMatch;
|
||||||
|
var bestScore = -1;
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.id == SubtitleTrack.auto.id || track.id == SubtitleTrack.off.id) continue;
|
||||||
|
if (!_languagesMatch(intent.language, track.language)) continue;
|
||||||
|
if (track.effectiveForced != intent.forced) continue;
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
if (_subtitleCodecsMatch(intent.codec, track.codec)) score += 5;
|
||||||
|
score += _titleScore(intent.title, track.title, null);
|
||||||
|
if (intent.isExternal == track.isExternal) score += 1;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMatch = track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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,
|
||||||
@@ -642,7 +692,7 @@ class TrackSelectionService {
|
|||||||
MediaServerUserProfile profile, {
|
MediaServerUserProfile profile, {
|
||||||
bool forcedOnly = false,
|
bool forcedOnly = false,
|
||||||
}) {
|
}) {
|
||||||
final candidates = forcedOnly ? availableTracks.where((track) => track.isForced).toList() : availableTracks;
|
final candidates = forcedOnly ? availableTracks.where((track) => track.effectiveForced).toList() : availableTracks;
|
||||||
if (candidates.isEmpty) return null;
|
if (candidates.isEmpty) return null;
|
||||||
|
|
||||||
final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false);
|
final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false);
|
||||||
@@ -675,7 +725,7 @@ class TrackSelectionService {
|
|||||||
|
|
||||||
SubtitleTrack? _findForcedSubtitleTrack(List<SubtitleTrack> availableTracks) {
|
SubtitleTrack? _findForcedSubtitleTrack(List<SubtitleTrack> availableTracks) {
|
||||||
for (final track in availableTracks) {
|
for (final track in availableTracks) {
|
||||||
if (track.isForced) return track;
|
if (track.effectiveForced) return track;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -931,30 +981,57 @@ class TrackSelectionService {
|
|||||||
/// nothing at all.
|
/// nothing at all.
|
||||||
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
|
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
|
||||||
List<SubtitleTrack> availableTracks,
|
List<SubtitleTrack> availableTracks,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitlePreference? preference,
|
||||||
AudioTrack? selectedAudioTrack, {
|
AudioTrack? selectedAudioTrack, {
|
||||||
bool waitForPendingSource = true,
|
bool waitForPendingSource = true,
|
||||||
}) {
|
}) {
|
||||||
// Priority 1: Try preferred track from navigation
|
// Priority 1: the caller's preference — an identity reference into this
|
||||||
if (preferredSubtitleTrack != null) {
|
// item, or a semantic intent carried across an item boundary.
|
||||||
if (preferredSubtitleTrack.id == 'no') {
|
switch (preference) {
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
|
case SubtitleOffPreference():
|
||||||
return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.navigation);
|
return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.navigation);
|
||||||
} else if (availableTracks.isNotEmpty) {
|
case SubtitleTrackPreference(:final track):
|
||||||
final subtitleToSelect = findBestSubtitleMatch(availableTracks, preferredSubtitleTrack);
|
if (track.id == 'no') {
|
||||||
if (subtitleToSelect != null) {
|
return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.navigation);
|
||||||
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
|
||||||
}
|
}
|
||||||
}
|
if (availableTracks.isNotEmpty) {
|
||||||
if (waitForPendingSource && preferredSubtitleTrack.id.startsWith('source:')) {
|
final subtitleToSelect = findBestSubtitleMatch(availableTracks, track);
|
||||||
// Only a row this catalog actually advertises can still show up
|
if (subtitleToSelect != null) {
|
||||||
// natively. An id the catalog does not carry — a stale preference from
|
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
||||||
// another media source — resolves the same way on every retry, so
|
}
|
||||||
// waiting for it would defer selection forever.
|
|
||||||
final sourceTrack = _sourceSubtitleTrack(preferredSubtitleTrack.id);
|
|
||||||
if (sourceTrack != null && !_hasCompleteDirectSourceCatalogFor(sourceTrack, availableTracks)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
if (waitForPendingSource && track.id.startsWith('source:')) {
|
||||||
|
// Only a row this catalog actually advertises can still show up
|
||||||
|
// natively. An id the catalog does not carry — a stale preference
|
||||||
|
// from another media source — resolves the same way on every retry,
|
||||||
|
// so waiting for it would defer selection forever.
|
||||||
|
final sourceTrack = _sourceSubtitleTrack(track.id);
|
||||||
|
if (sourceTrack != null && !_hasCompleteDirectSourceCatalogFor(sourceTrack, availableTracks)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case SubtitleIntentPreference(:final intent):
|
||||||
|
if (availableTracks.isNotEmpty) {
|
||||||
|
final match = findNativeTrackForIntent(intent, availableTracks);
|
||||||
|
if (match != null) {
|
||||||
|
return TrackSelectionResult(match, TrackSelectionPriority.navigation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (waitForPendingSource) {
|
||||||
|
// Mirror of the source-id rule above: only an intent this catalog
|
||||||
|
// can actually serve may still show up natively. A class-preserving
|
||||||
|
// row proves the catalog can serve it; an incomplete direct catalog
|
||||||
|
// means its native track may not have arrived yet. An unservable
|
||||||
|
// intent resolves the same way on every retry — decline immediately
|
||||||
|
// and let the ladder decide.
|
||||||
|
final servableRow = findSourceTrackForIntent(intent, plexMediaInfo?.subtitleTracks ?? const []);
|
||||||
|
if (servableRow != null && !_hasCompleteDirectSourceCatalogFor(servableRow, availableTracks)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appLogger.d('Subtitle intent declined: $intent');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Trust the server's selected track. Plex computes this from
|
// Priority 2: Trust the server's selected track. Plex computes this from
|
||||||
@@ -1029,8 +1106,8 @@ class TrackSelectionService {
|
|||||||
/// Select and apply audio and subtitle tracks based on preferences
|
/// Select and apply audio and subtitle tracks based on preferences
|
||||||
Future<bool> selectAndApplyTracks({
|
Future<bool> selectAndApplyTracks({
|
||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
double? defaultPlaybackSpeed,
|
double? defaultPlaybackSpeed,
|
||||||
Function(AudioTrack)? onAudioTrackChanged,
|
Function(AudioTrack)? onAudioTrackChanged,
|
||||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||||
@@ -1113,11 +1190,17 @@ class TrackSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply preferred secondary subtitle track if provided (mpv-only)
|
// Apply preferred secondary subtitle track if provided (mpv-only)
|
||||||
if (preferredSecondarySubtitleTrack != null &&
|
final secondaryPreference = preferredSecondarySubtitleTrack;
|
||||||
preferredSecondarySubtitleTrack.id != 'no' &&
|
if (secondaryPreference != null &&
|
||||||
|
secondaryPreference is! SubtitleOffPreference &&
|
||||||
player.supportsSecondarySubtitles &&
|
player.supportsSecondarySubtitles &&
|
||||||
realSubtitleTracks.isNotEmpty) {
|
realSubtitleTracks.isNotEmpty) {
|
||||||
final secondaryMatch = findBestSubtitleMatch(realSubtitleTracks, preferredSecondarySubtitleTrack);
|
final secondaryMatch = switch (secondaryPreference) {
|
||||||
|
SubtitleOffPreference() => null,
|
||||||
|
SubtitleTrackPreference(:final track) =>
|
||||||
|
track.id == 'no' ? null : findBestSubtitleMatch(realSubtitleTracks, track),
|
||||||
|
SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, realSubtitleTracks),
|
||||||
|
};
|
||||||
if (secondaryMatch != null && secondaryMatch.id != 'no') {
|
if (secondaryMatch != null && secondaryMatch.id != 'no') {
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}',
|
'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}',
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import '../media/media_source_info.dart';
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
|
import 'track_label_builder.dart';
|
||||||
|
|
||||||
|
/// Effective forced-ness, mirroring server behavior (#1716/#1717): a subtitle
|
||||||
|
/// stream counts as forced when its flag is set OR its title says "forced".
|
||||||
|
/// Every forced comparison must use these getters on BOTH sides — the raw
|
||||||
|
/// flags stay untouched so parsing and the file-info UI keep server truth.
|
||||||
|
|
||||||
|
extension MediaSubtitleTrackForcedSemantics on MediaSubtitleTrack {
|
||||||
|
bool get effectiveForced => forced || titleSaysForced(title) || titleSaysForced(displayTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SubtitleTrackForcedSemantics on SubtitleTrack {
|
||||||
|
bool get effectiveForced => isForced || titleSaysForced(title);
|
||||||
|
}
|
||||||
@@ -115,6 +115,11 @@ Set<String> _subtitleCodecAliases(String? codec) {
|
|||||||
|
|
||||||
String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]+'), '_');
|
String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]+'), '_');
|
||||||
|
|
||||||
|
/// Whether a track title declares the stream forced ("FR Forced [ASS]",
|
||||||
|
/// "French (Forced)"). Whole-token match, so "unforced" does not qualify.
|
||||||
|
/// Plex itself treats such streams as forced even when the API flag is false.
|
||||||
|
bool titleSaysForced(String? value) => _metadataToken(value ?? '').split('_').contains('FORCED');
|
||||||
|
|
||||||
class TrackLabelBuilder {
|
class TrackLabelBuilder {
|
||||||
TrackLabelBuilder._();
|
TrackLabelBuilder._();
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ class TrackLabelBuilder {
|
|||||||
techParts: [if (codec != null && codec.isNotEmpty) CodecUtils.formatSubtitleCodec(codec)],
|
techParts: [if (codec != null && codec.isNotEmpty) CodecUtils.formatSubtitleCodec(codec)],
|
||||||
fallbackPrefix: 'Track',
|
fallbackPrefix: 'Track',
|
||||||
index: index,
|
index: index,
|
||||||
forced: forced || _saysForced(cleanedTitle),
|
forced: forced || titleSaysForced(cleanedTitle),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +200,7 @@ class TrackLabelBuilder {
|
|||||||
primary = '$fallbackPrefix ${index + 1}';
|
primary = '$fallbackPrefix ${index + 1}';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forced && !_saysForced(primary)) {
|
if (forced && !titleSaysForced(primary)) {
|
||||||
primary = '$primary (Forced)';
|
primary = '$primary (Forced)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +208,6 @@ class TrackLabelBuilder {
|
|||||||
return TrackLabel(primary, secondaryParts.isEmpty ? null : secondaryParts.join(' · '));
|
return TrackLabel(primary, secondaryParts.isEmpty ? null : secondaryParts.join(' · '));
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _saysForced(String? value) => _metadataToken(value ?? '').split('_').contains('FORCED');
|
|
||||||
|
|
||||||
static bool _restatesLanguage(String title, String languageDisplay, List<String?> rawLanguageValues) {
|
static bool _restatesLanguage(String title, String languageDisplay, List<String?> rawLanguageValues) {
|
||||||
final normalized = title.trim().toLowerCase();
|
final normalized = title.trim().toLowerCase();
|
||||||
if (normalized == languageDisplay.trim().toLowerCase()) return true;
|
if (normalized == languageDisplay.trim().toLowerCase()) return true;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:plezy/providers/playback_state_provider.dart';
|
|||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/screens/video_player_screen.dart';
|
import 'package:plezy/screens/video_player_screen.dart';
|
||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../test_helpers/media_items.dart';
|
import '../../test_helpers/media_items.dart';
|
||||||
@@ -59,14 +60,26 @@ void main() {
|
|||||||
nativeTrack: SubtitleTrack.off,
|
nativeTrack: SubtitleTrack.off,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, isNotNull);
|
expect(result, isA<SubtitleIntentPreference>());
|
||||||
expect(result!.id, 'navigation');
|
final intent = (result! as SubtitleIntentPreference).intent;
|
||||||
expect(result.title, committed.title);
|
expect(intent.title, committed.title);
|
||||||
expect(result.language, committed.language);
|
expect(intent.language, committed.language);
|
||||||
expect(result.codec, committed.codec);
|
expect(intent.codec, committed.codec);
|
||||||
expect(result.isForced, isTrue);
|
expect(intent.forced, isTrue);
|
||||||
expect(result.isExternal, isTrue);
|
expect(intent.isExternal, isTrue);
|
||||||
expect(result.uri, isNull);
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
final result = subtitlePreferenceForItemChange(
|
||||||
|
hasCommittedSelection: true,
|
||||||
|
committedTrack: committed,
|
||||||
|
nativeTrack: SubtitleTrack.off,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isA<SubtitleIntentPreference>());
|
||||||
|
expect((result! as SubtitleIntentPreference).intent.forced, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('item-change subtitle preference preserves committed off and empty secondary slots', () {
|
test('item-change subtitle preference preserves committed off and empty secondary slots', () {
|
||||||
@@ -76,7 +89,7 @@ void main() {
|
|||||||
committedTrack: SubtitleTrack.off,
|
committedTrack: SubtitleTrack.off,
|
||||||
nativeTrack: const SubtitleTrack(id: '7', language: 'eng'),
|
nativeTrack: const SubtitleTrack(id: '7', language: 'eng'),
|
||||||
),
|
),
|
||||||
same(SubtitleTrack.off),
|
const SubtitlePreference.off(),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
subtitlePreferenceForItemChange(
|
subtitlePreferenceForItemChange(
|
||||||
@@ -84,7 +97,7 @@ void main() {
|
|||||||
committedTrack: null,
|
committedTrack: null,
|
||||||
nativeTrack: const SubtitleTrack(id: '8', language: 'swe'),
|
nativeTrack: const SubtitleTrack(id: '8', language: 'swe'),
|
||||||
),
|
),
|
||||||
same(SubtitleTrack.off),
|
const SubtitlePreference.off(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,9 +113,8 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result?.id, 'navigation');
|
expect(result, isA<SubtitleIntentPreference>());
|
||||||
expect(result?.language, 'eng');
|
expect((result! as SubtitleIntentPreference).intent.language, 'eng');
|
||||||
expect(result?.uri, isNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:plezy/models/transcode_quality_preset.dart';
|
|||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/services/jellyfin_client.dart';
|
import 'package:plezy/services/jellyfin_client.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
import 'package:plezy/utils/device_identity.dart';
|
import 'package:plezy/utils/device_identity.dart';
|
||||||
import 'package:plezy/utils/media_server_http_client.dart';
|
import 'package:plezy/utils/media_server_http_client.dart';
|
||||||
|
|
||||||
@@ -1060,7 +1061,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
addTearDown(scoped.close);
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
Future<PlaybackInitializationResult> initialize(SubtitleTrack preference) {
|
Future<PlaybackInitializationResult> initialize(SubtitlePreference preference) {
|
||||||
return scoped.getPlaybackInitialization(
|
return scoped.getPlaybackInitialization(
|
||||||
PlaybackInitializationOptions(
|
PlaybackInitializationOptions(
|
||||||
metadata: testMediaItem(
|
metadata: testMediaItem(
|
||||||
@@ -1082,31 +1083,41 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final result = await initialize(
|
final result = await initialize(
|
||||||
const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
|
const SubtitlePreference.track(
|
||||||
|
SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expectRequestedSubtitleIndex(4);
|
expectRequestedSubtitleIndex(4);
|
||||||
|
|
||||||
await initialize(const SubtitleTrack(id: 'navigation', title: 'English - SRT', language: 'eng', codec: 'srt'));
|
await initialize(
|
||||||
|
const SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt'),
|
||||||
|
),
|
||||||
|
);
|
||||||
expectRequestedSubtitleIndex(3);
|
expectRequestedSubtitleIndex(3);
|
||||||
|
|
||||||
await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'));
|
await initialize(
|
||||||
|
const SubtitlePreference.track(
|
||||||
|
SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'),
|
||||||
|
),
|
||||||
|
);
|
||||||
expectRequestedSubtitleIndex(4);
|
expectRequestedSubtitleIndex(4);
|
||||||
|
|
||||||
await initialize(
|
await initialize(
|
||||||
const SubtitleTrack(
|
const SubtitlePreference.track(
|
||||||
id: 'source:3',
|
SubtitleTrack(id: 'source:3', title: 'English Forced - SRT', language: 'eng', codec: 'srt', isForced: true),
|
||||||
title: 'English Forced - SRT',
|
|
||||||
language: 'eng',
|
|
||||||
codec: 'srt',
|
|
||||||
isForced: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expectRequestedSubtitleIndex(5);
|
expectRequestedSubtitleIndex(5);
|
||||||
|
|
||||||
await initialize(SubtitleTrack.off);
|
await initialize(const SubtitlePreference.off());
|
||||||
expectRequestedSubtitleIndex(-1);
|
expectRequestedSubtitleIndex(-1);
|
||||||
|
|
||||||
await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt'));
|
await initialize(
|
||||||
|
const SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'jpn', forced: false, title: 'Japanese - SRT', codec: 'srt'),
|
||||||
|
),
|
||||||
|
);
|
||||||
expectRequestedSubtitleIndex(null);
|
expectRequestedSubtitleIndex(null);
|
||||||
|
|
||||||
expect(result.playMethod, 'DirectStream');
|
expect(result.playMethod, 'DirectStream');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_source_info.dart';
|
|||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
|
|
||||||
import '../test_helpers/media_items.dart';
|
import '../test_helpers/media_items.dart';
|
||||||
|
|
||||||
@@ -17,13 +18,14 @@ MediaSubtitleTrack _sourceSubtitle(
|
|||||||
bool external = false,
|
bool external = false,
|
||||||
bool usesExternalDelivery = false,
|
bool usesExternalDelivery = false,
|
||||||
String? key,
|
String? key,
|
||||||
|
String? title,
|
||||||
}) {
|
}) {
|
||||||
return MediaSubtitleTrack(
|
return MediaSubtitleTrack(
|
||||||
id: id,
|
id: id,
|
||||||
language: language,
|
language: language,
|
||||||
languageCode: language,
|
languageCode: language,
|
||||||
codec: codec,
|
codec: codec,
|
||||||
title: 'Subtitle $id',
|
title: title ?? 'Subtitle $id',
|
||||||
selected: selected,
|
selected: selected,
|
||||||
forced: forced,
|
forced: forced,
|
||||||
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
|
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
|
||||||
@@ -229,7 +231,7 @@ void main() {
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
||||||
sidecars: [_sidecar(2)],
|
sidecars: [_sidecar(2)],
|
||||||
preferredSubtitleTrack: SubtitleTrack.off,
|
preferredSubtitleTrack: const SubtitlePreference.off(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.isOff, isTrue);
|
expect(result.isOff, isTrue);
|
||||||
@@ -244,7 +246,7 @@ void main() {
|
|||||||
_sidecar(2, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
_sidecar(2, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||||
_sidecar(3, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
_sidecar(3, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||||
],
|
],
|
||||||
preferredSubtitleTrack: SubtitleTrack.off,
|
preferredSubtitleTrack: const SubtitlePreference.off(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.isOff, isTrue);
|
expect(result.isOff, isTrue);
|
||||||
@@ -266,7 +268,9 @@ void main() {
|
|||||||
_sidecar(2),
|
_sidecar(2),
|
||||||
_sidecar(3, language: 'swe'),
|
_sidecar(3, language: 'swe'),
|
||||||
],
|
],
|
||||||
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
|
||||||
|
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.primarySourceStreamId, 3);
|
expect(result.primarySourceStreamId, 3);
|
||||||
@@ -301,7 +305,9 @@ void main() {
|
|||||||
_sidecar(2, isContainer: true, uri: 'https://example.test/video.mkv'),
|
_sidecar(2, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||||
_sidecar(3, isContainer: true, uri: 'https://example.test/video.mkv'),
|
_sidecar(3, isContainer: true, uri: 'https://example.test/video.mkv'),
|
||||||
],
|
],
|
||||||
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
|
||||||
|
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.primarySourceStreamId, 3);
|
expect(result.primarySourceStreamId, 3);
|
||||||
@@ -318,13 +324,15 @@ void main() {
|
|||||||
_sidecar(3),
|
_sidecar(3),
|
||||||
_sidecar(7, language: 'fra'),
|
_sidecar(7, language: 'fra'),
|
||||||
],
|
],
|
||||||
preferredSubtitleTrack: const SubtitleTrack(
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||||
id: 'source:3',
|
SubtitleTrack(
|
||||||
title: 'French from the previous source',
|
id: 'source:3',
|
||||||
language: 'fra',
|
title: 'French from the previous source',
|
||||||
codec: 'srt',
|
language: 'fra',
|
||||||
isExternal: true,
|
codec: 'srt',
|
||||||
uri: 'https://example.test/previous/3.srt',
|
isExternal: true,
|
||||||
|
uri: 'https://example.test/previous/3.srt',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
preserveSourceIdentity: false,
|
preserveSourceIdentity: false,
|
||||||
);
|
);
|
||||||
@@ -337,11 +345,8 @@ void main() {
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng'), _sourceSubtitle(7, language: 'spa', selected: true)]),
|
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng'), _sourceSubtitle(7, language: 'spa', selected: true)]),
|
||||||
sidecars: const [],
|
sidecars: const [],
|
||||||
preferredSubtitleTrack: const SubtitleTrack(
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||||
id: 'source:3',
|
SubtitleTrack(id: 'source:3', title: 'French from the previous source', language: 'fra', codec: 'srt'),
|
||||||
title: 'French from the previous source',
|
|
||||||
language: 'fra',
|
|
||||||
codec: 'srt',
|
|
||||||
),
|
),
|
||||||
preserveSourceIdentity: false,
|
preserveSourceIdentity: false,
|
||||||
);
|
);
|
||||||
@@ -360,12 +365,14 @@ void main() {
|
|||||||
_sidecar(7),
|
_sidecar(7),
|
||||||
_sidecar(9, language: 'fra'),
|
_sidecar(9, language: 'fra'),
|
||||||
],
|
],
|
||||||
preferredSubtitleTrack: const SubtitleTrack(
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||||
id: 'navigation',
|
SubtitleIntent(
|
||||||
title: 'English from the previous episode',
|
language: 'eng',
|
||||||
language: 'eng',
|
forced: false,
|
||||||
codec: 'srt',
|
title: 'English from the previous episode',
|
||||||
isExternal: true,
|
codec: 'srt',
|
||||||
|
isExternal: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,13 +389,14 @@ void main() {
|
|||||||
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
|
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
|
||||||
]),
|
]),
|
||||||
sidecars: [_sidecar(7), _sidecar(8)],
|
sidecars: [_sidecar(7), _sidecar(8)],
|
||||||
preferredSubtitleTrack: const SubtitleTrack(
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||||
id: 'navigation',
|
SubtitleIntent(
|
||||||
title: 'English forced from the previous episode',
|
language: 'eng',
|
||||||
language: 'eng',
|
forced: true,
|
||||||
codec: 'srt',
|
title: 'English forced from the previous episode',
|
||||||
isForced: true,
|
codec: 'srt',
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -397,6 +405,82 @@ void main() {
|
|||||||
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('issue #1716/#1717 forced-class carry-over', () {
|
||||||
|
const forcedIntent = SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'fra', forced: true, title: 'FR Forced [ASS]', codec: 'srt', isExternal: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("picks the next episode's title-only forced row", () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([
|
||||||
|
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
||||||
|
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
||||||
|
]),
|
||||||
|
sidecars: [_sidecar(7, language: 'fra'), _sidecar(8, language: 'fra')],
|
||||||
|
preferredSubtitleTrack: forcedIntent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.primarySourceStreamId, 8);
|
||||||
|
expect(result.primaryTrack.isForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks a flag-forced row for a title-forced intent (cross-form)', () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([
|
||||||
|
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
||||||
|
_sourceSubtitle(8, language: 'fra', forced: true, usesExternalDelivery: true),
|
||||||
|
]),
|
||||||
|
sidecars: [_sidecar(7, language: 'fra'), _sidecar(8, language: 'fra')],
|
||||||
|
preferredSubtitleTrack: forcedIntent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.primarySourceStreamId, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declines to the server-selected full row when no forced row exists', () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([
|
||||||
|
_sourceSubtitle(7, language: 'fra', selected: true, usesExternalDelivery: true),
|
||||||
|
_sourceSubtitle(9, language: 'eng', usesExternalDelivery: true),
|
||||||
|
]),
|
||||||
|
sidecars: [_sidecar(7, language: 'fra'), _sidecar(9)],
|
||||||
|
preferredSubtitleTrack: forcedIntent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.primarySourceStreamId, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitles stay off when no forced row exists and nothing is selected', () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true)]),
|
||||||
|
sidecars: [_sidecar(7, language: 'fra')],
|
||||||
|
preferredSubtitleTrack: forcedIntent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOff, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full intent does not inherit a forced-only catalog (symmetric)', () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([
|
||||||
|
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
||||||
|
_sourceSubtitle(9, language: 'eng', selected: true, usesExternalDelivery: true),
|
||||||
|
]),
|
||||||
|
sidecars: [_sidecar(8, language: 'fra'), _sidecar(9)],
|
||||||
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'fra', forced: false, title: 'French', codec: 'srt', isExternal: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.primarySourceStreamId, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
@@ -433,7 +517,9 @@ void main() {
|
|||||||
_sidecar(2),
|
_sidecar(2),
|
||||||
_sidecar(3, language: 'swe'),
|
_sidecar(3, language: 'swe'),
|
||||||
],
|
],
|
||||||
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(
|
||||||
|
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.primarySourceStreamId, 2);
|
expect(result.primarySourceStreamId, 2);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SubtitleIntent.fromTrack', () {
|
||||||
|
test('returns null for null, off, and metadata-less tracks', () {
|
||||||
|
expect(SubtitleIntent.fromTrack(null), isNull);
|
||||||
|
expect(SubtitleIntent.fromTrack(SubtitleTrack.off), isNull);
|
||||||
|
expect(SubtitleIntent.fromTrack(const SubtitleTrack(id: '3')), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captures effective forced-ness from the title (#1716)', () {
|
||||||
|
final intent = SubtitleIntent.fromTrack(
|
||||||
|
const SubtitleTrack(id: 'source:8', title: 'FR Forced [ASS]', language: 'fre', codec: 'ass'),
|
||||||
|
);
|
||||||
|
expect(intent, isNotNull);
|
||||||
|
expect(intent!.forced, isTrue);
|
||||||
|
expect(intent.language, 'fre');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captures effective forced-ness from the flag', () {
|
||||||
|
final intent = SubtitleIntent.fromTrack(
|
||||||
|
const SubtitleTrack(id: '4', language: 'fre', isForced: true),
|
||||||
|
);
|
||||||
|
expect(intent!.forced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full track produces a non-forced intent', () {
|
||||||
|
final intent = SubtitleIntent.fromTrack(
|
||||||
|
const SubtitleTrack(id: '4', title: 'French', language: 'fre', codec: 'srt'),
|
||||||
|
);
|
||||||
|
expect(intent!.forced, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SubtitlePreference.trackOrNull', () {
|
||||||
|
test('maps null, off, and real tracks', () {
|
||||||
|
expect(SubtitlePreference.trackOrNull(null), isNull);
|
||||||
|
expect(SubtitlePreference.trackOrNull(SubtitleTrack.off), const SubtitlePreference.off());
|
||||||
|
const track = SubtitleTrack(id: 'source:5', language: 'eng');
|
||||||
|
expect(SubtitlePreference.trackOrNull(track), const SubtitlePreference.track(track));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SubtitlePreference.demoteToIntent', () {
|
||||||
|
test('off and intents pass through, null stays null', () {
|
||||||
|
const off = SubtitlePreference.off();
|
||||||
|
expect(SubtitlePreference.demoteToIntent(off), off);
|
||||||
|
const intent = SubtitlePreference.intent(SubtitleIntent(language: 'fre', forced: true));
|
||||||
|
expect(SubtitlePreference.demoteToIntent(intent), intent);
|
||||||
|
expect(SubtitlePreference.demoteToIntent(null), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track references become intents with effective forced-ness', () {
|
||||||
|
final demoted = SubtitlePreference.demoteToIntent(
|
||||||
|
const SubtitlePreference.track(SubtitleTrack(id: 'source:8', title: 'FR Forced', language: 'fre', codec: 'ass')),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
demoted,
|
||||||
|
const SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced', codec: 'ass'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidecar uri references also lose their identity', () {
|
||||||
|
final demoted = SubtitlePreference.demoteToIntent(
|
||||||
|
SubtitlePreference.track(
|
||||||
|
SubtitleTrack.uri('https://server/library/streams/9.srt', language: 'eng', codec: 'srt'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(demoted, isA<SubtitleIntentPreference>());
|
||||||
|
expect((demoted! as SubtitleIntentPreference).intent.isExternal, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata-less track references demote to null', () {
|
||||||
|
expect(
|
||||||
|
SubtitlePreference.demoteToIntent(const SubtitlePreference.track(SubtitleTrack(id: '2'))),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:plezy/mpv/player/player_stream_controllers.dart';
|
|||||||
import 'package:plezy/screens/video_player_screen.dart';
|
import 'package:plezy/screens/video_player_screen.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
import 'package:plezy/services/track_manager.dart';
|
import 'package:plezy/services/track_manager.dart';
|
||||||
|
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
@@ -162,7 +163,7 @@ TrackManager _make({
|
|||||||
metadata: metadata ?? _meta(),
|
metadata: metadata ?? _meta(),
|
||||||
mediaInfo: mediaInfo,
|
mediaInfo: mediaInfo,
|
||||||
preferredAudioTrack: preferredAudioTrack,
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack),
|
||||||
showMessage: showMessage,
|
showMessage: showMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -660,7 +661,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(handledLocally, isTrue);
|
expect(handledLocally, isTrue);
|
||||||
expect(mgr.preferredSubtitleTrack?.id, 'source:31');
|
expect((mgr.preferredSubtitleTrack! as SubtitleTrackPreference).track.id, 'source:31');
|
||||||
expect(persistedTrack?.id, 'source:31');
|
expect(persistedTrack?.id, 'source:31');
|
||||||
expect(persistedSourceStreamId, 31);
|
expect(persistedSourceStreamId, 31);
|
||||||
expect(player.selectedSubtitle, isEmpty);
|
expect(player.selectedSubtitle, isEmpty);
|
||||||
@@ -1266,7 +1267,7 @@ void main() {
|
|||||||
await mgr.invalidatePendingSelection();
|
await mgr.invalidatePendingSelection();
|
||||||
|
|
||||||
mgr.preferredAudioTrack = audioTracks[1];
|
mgr.preferredAudioTrack = audioTracks[1];
|
||||||
mgr.preferredSubtitleTrack = subtitleTracks[1];
|
mgr.preferredSubtitleTrack = SubtitlePreference.track(subtitleTracks[1]);
|
||||||
final replacementApplication = mgr.applyTrackSelection();
|
final replacementApplication = mgr.applyTrackSelection();
|
||||||
await _drainAsync();
|
await _drainAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:plezy/media/media_source_info.dart';
|
|||||||
import 'package:plezy/models/jellyfin/jellyfin_user_profile.dart';
|
import 'package:plezy/models/jellyfin/jellyfin_user_profile.dart';
|
||||||
import 'package:plezy/models/plex/plex_user_profile.dart';
|
import 'package:plezy/models/plex/plex_user_profile.dart';
|
||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
import 'package:plezy/services/track_selection_service.dart';
|
import 'package:plezy/services/track_selection_service.dart';
|
||||||
import '../test_helpers/media_items.dart';
|
import '../test_helpers/media_items.dart';
|
||||||
|
|
||||||
@@ -453,14 +454,14 @@ void main() {
|
|||||||
group('selectSubtitleTrack', () {
|
group('selectSubtitleTrack', () {
|
||||||
test('Priority 1: preferred id="no" forces subtitles off', () {
|
test('Priority 1: preferred id="no" forces subtitles off', () {
|
||||||
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
||||||
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null)!;
|
final result = _svc().selectSubtitleTrack(tracks, const SubtitlePreference.off(), null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.navigation);
|
expect(result.priority, TrackSelectionPriority.navigation);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Priority 1: preferred subtitle from navigation matches by language', () {
|
test('Priority 1: preferred subtitle from navigation matches by language', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||||
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null)!;
|
final result = _svc().selectSubtitleTrack(tracks, SubtitlePreference.track(_sub('99', lang: 'fre')), null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.navigation);
|
expect(result.priority, TrackSelectionPriority.navigation);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -486,7 +487,7 @@ void main() {
|
|||||||
|
|
||||||
final preferredResult = service.selectSubtitleTrack(
|
final preferredResult = service.selectSubtitleTrack(
|
||||||
[nativeTrack],
|
[nativeTrack],
|
||||||
const SubtitleTrack(id: 'source:20', codec: 'ass'),
|
const SubtitlePreference.track(SubtitleTrack(id: 'source:20', codec: 'ass')),
|
||||||
null,
|
null,
|
||||||
)!;
|
)!;
|
||||||
final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!;
|
final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!;
|
||||||
@@ -523,7 +524,7 @@ void main() {
|
|||||||
|
|
||||||
final preferredResult = service.selectSubtitleTrack(
|
final preferredResult = service.selectSubtitleTrack(
|
||||||
nativeTracks,
|
nativeTracks,
|
||||||
const SubtitleTrack(id: 'source:40', codec: 'ass'),
|
const SubtitlePreference.track(SubtitleTrack(id: 'source:40', codec: 'ass')),
|
||||||
null,
|
null,
|
||||||
)!;
|
)!;
|
||||||
final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!;
|
final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!;
|
||||||
@@ -540,7 +541,7 @@ void main() {
|
|||||||
|
|
||||||
final result = _svc(
|
final result = _svc(
|
||||||
info: _info(subs: plexTracks),
|
info: _info(subs: plexTracks),
|
||||||
).selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:50', codec: 'ass'), null)!;
|
).selectSubtitleTrack(nativeTracks, const SubtitlePreference.track(SubtitleTrack(id: 'source:50', codec: 'ass')), null)!;
|
||||||
|
|
||||||
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
||||||
expect(result.track.id, 'native-default');
|
expect(result.track.id, 'native-default');
|
||||||
@@ -552,7 +553,11 @@ void main() {
|
|||||||
final service = _svc(info: _info(subs: plexTracks));
|
final service = _svc(info: _info(subs: plexTracks));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
service.selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:60', codec: 'ass'), null),
|
service.selectSubtitleTrack(
|
||||||
|
nativeTracks,
|
||||||
|
const SubtitlePreference.track(SubtitleTrack(id: 'source:60', codec: 'ass')),
|
||||||
|
null,
|
||||||
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull);
|
expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull);
|
||||||
@@ -607,8 +612,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
final service = _svc(info: info);
|
final service = _svc(info: info);
|
||||||
|
|
||||||
expect(service.selectSubtitleTrack(const [first], preferred, null), isNull);
|
expect(service.selectSubtitleTrack(const [first], SubtitlePreference.track(preferred), null), isNull);
|
||||||
expect(service.selectSubtitleTrack(const [first, second], preferred, null)?.track.id, 'native-1');
|
expect(
|
||||||
|
service.selectSubtitleTrack(const [first, second], SubtitlePreference.track(preferred), null)?.track.id,
|
||||||
|
'native-1',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preferred keyed source does not fuzzy-match an early same-language container track', () {
|
test('preferred keyed source does not fuzzy-match an early same-language container track', () {
|
||||||
@@ -634,7 +642,10 @@ void main() {
|
|||||||
uri: 'https://example.test/video.mkv',
|
uri: 'https://example.test/video.mkv',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(_svc(info: info).selectSubtitleTrack(const [earlyContainer], preferred, null), isNull);
|
expect(
|
||||||
|
_svc(info: info).selectSubtitleTrack(const [earlyContainer], SubtitlePreference.track(preferred), null),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
|
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
|
||||||
@@ -742,7 +753,9 @@ void main() {
|
|||||||
info: directPlayInfo(),
|
info: directPlayInfo(),
|
||||||
).selectSubtitleTrack(
|
).selectSubtitleTrack(
|
||||||
nativeTracks,
|
nativeTracks,
|
||||||
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
|
SubtitlePreference.track(
|
||||||
|
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
|
||||||
|
),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -756,7 +769,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: directPlayInfo(),
|
info: directPlayInfo(),
|
||||||
).selectSubtitleTrack(nativeTracks, _sub('source:9', lang: 'kor', codec: 'srt'), null);
|
).selectSubtitleTrack(nativeTracks, SubtitlePreference.track(_sub('source:9', lang: 'kor', codec: 'srt')), null);
|
||||||
|
|
||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result!.track.id, '1');
|
expect(result!.track.id, '1');
|
||||||
@@ -811,7 +824,7 @@ void main() {
|
|||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: info,
|
info: info,
|
||||||
);
|
);
|
||||||
final preferred = _sub('source:3', lang: 'eng', codec: 'srt');
|
final preferred = SubtitlePreference.track(_sub('source:3', lang: 'eng', codec: 'srt'));
|
||||||
|
|
||||||
expect(service.selectSubtitleTrack(nativeTracks, preferred, null), isNull);
|
expect(service.selectSubtitleTrack(nativeTracks, preferred, null), isNull);
|
||||||
|
|
||||||
@@ -878,6 +891,16 @@ void main() {
|
|||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Jellyfin SubtitleMode.OnlyForced honors a title-only forced subtitle (#1716)', () {
|
||||||
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'eng', title: 'English Forced')];
|
||||||
|
final result = _svc(
|
||||||
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||||
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
|
expect(result.track.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
test('Jellyfin SubtitleMode.OnlyForced turns off when no forced subtitle exists', () {
|
test('Jellyfin SubtitleMode.OnlyForced turns off when no forced subtitle exists', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'jpn')];
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'jpn')];
|
||||||
final result = _svc(
|
final result = _svc(
|
||||||
@@ -1026,6 +1049,189 @@ void main() {
|
|||||||
expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 30);
|
expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 30);
|
||||||
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 31);
|
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 31);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cross-form pairs: one side flags forced in the container, the other only
|
||||||
|
// says it in the title. Effective-forced semantics (#1716) treat both forms
|
||||||
|
// as the same class, so the pair still gets the +2 agreement nudge.
|
||||||
|
test('flag-forced native track maps to a title-only forced row', () {
|
||||||
|
final plexTracks = [
|
||||||
|
_plexSub(50, index: 0, languageCode: 'fre', title: 'FR Forced', codec: 'ass', forced: false),
|
||||||
|
_plexSub(51, index: 1, languageCode: 'fre', codec: 'ass', forced: false),
|
||||||
|
];
|
||||||
|
final mpvForced = _sub('2_0', lang: 'fre', codec: 'ass', isForced: true);
|
||||||
|
final mpvRegular = _sub('2_1', lang: 'fre', codec: 'ass');
|
||||||
|
final allMpv = [mpvForced, mpvRegular];
|
||||||
|
|
||||||
|
expect(findPlexTrackForMpvSubtitle(mpvForced, plexTracks, allMpvTracks: allMpv)?.id, 50);
|
||||||
|
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 51);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title-only forced native track maps to a flag-forced row', () {
|
||||||
|
final plexTracks = [
|
||||||
|
_plexSub(60, index: 0, languageCode: 'fre', codec: 'ass', forced: true),
|
||||||
|
_plexSub(61, index: 1, languageCode: 'fre', codec: 'ass', forced: false),
|
||||||
|
];
|
||||||
|
final mpvForcedByName = _sub('2_0', lang: 'fre', title: 'FR Forced [ASS]', codec: 'ass');
|
||||||
|
final mpvRegular = _sub('2_1', lang: 'fre', codec: 'ass');
|
||||||
|
final allMpv = [mpvForcedByName, mpvRegular];
|
||||||
|
|
||||||
|
expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 60);
|
||||||
|
expect(findPlexTrackForMpvSubtitle(mpvRegular, plexTracks, allMpvTracks: allMpv)?.id, 61);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Cross-item intent matching (#1716/#1717): language and effective
|
||||||
|
// forced-ness are hard requirements — the intent's class is preserved or
|
||||||
|
// the match declines so the ladder falls to the server's own selection.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
group('findSourceTrackForIntent', () {
|
||||||
|
const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass');
|
||||||
|
const fullIntent = SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'srt');
|
||||||
|
|
||||||
|
test('forced intent picks the title-only forced row over the full row', () {
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
_plexSub(2, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(forcedIntent, rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced intent picks a flag-forced row (cross-form)', () {
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
_plexSub(2, languageCode: 'fre', codec: 'ass', forced: true),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(forcedIntent, rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced intent declines when only full same-language rows exist', () {
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
_plexSub(2, languageCode: 'eng', title: 'English', codec: 'srt'),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(forcedIntent, rows), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full intent declines when only forced rows share the language', () {
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'fre', title: 'FR Forced', codec: 'srt'),
|
||||||
|
_plexSub(2, languageCode: 'fre', codec: 'ass', forced: true),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(fullIntent, rows), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('codec and title break ties between same-class rows', () {
|
||||||
|
final rows = [
|
||||||
|
_plexSub(1, languageCode: 'fre', title: 'Commentary', codec: 'ass'),
|
||||||
|
_plexSub(2, languageCode: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
];
|
||||||
|
expect(findSourceTrackForIntent(fullIntent, rows)?.id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('language-less intents decline', () {
|
||||||
|
const intent = SubtitleIntent(forced: false, title: 'French', codec: 'srt');
|
||||||
|
expect(findSourceTrackForIntent(intent, [_plexSub(1, languageCode: 'fre')]), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('findNativeTrackForIntent', () {
|
||||||
|
const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass');
|
||||||
|
|
||||||
|
test('forced intent picks the forced native track in either form', () {
|
||||||
|
final byTitle = [
|
||||||
|
_sub('1', lang: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
_sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||||
|
];
|
||||||
|
final byFlag = [
|
||||||
|
_sub('1', lang: 'fre', title: 'French', codec: 'srt'),
|
||||||
|
_sub('2', lang: 'fre', codec: 'ass', isForced: true),
|
||||||
|
];
|
||||||
|
expect(findNativeTrackForIntent(forcedIntent, byTitle)?.id, '2');
|
||||||
|
expect(findNativeTrackForIntent(forcedIntent, byFlag)?.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declines symmetrically and skips the auto/off sentinels', () {
|
||||||
|
final fullOnly = [SubtitleTrack.auto, SubtitleTrack.off, _sub('1', lang: 'fre', title: 'French', codec: 'srt')];
|
||||||
|
expect(findNativeTrackForIntent(forcedIntent, fullOnly), isNull);
|
||||||
|
|
||||||
|
const fullIntent = SubtitleIntent(language: 'fre', forced: false);
|
||||||
|
final forcedOnly = [_sub('1', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||||
|
expect(findNativeTrackForIntent(fullIntent, forcedOnly), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('selectSubtitleTrack - intent preferences (#1716/#1717)', () {
|
||||||
|
const forcedIntent = SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a class-preserving intent match carries navigation priority', () {
|
||||||
|
final tracks = [_sub('1', lang: 'fre', codec: 'ass'), _sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||||
|
final result = _svc().selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.navigation);
|
||||||
|
expect(result.track.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a declined forced intent falls to the server-selected full track', () {
|
||||||
|
final tracks = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||||
|
final info = _info(subs: [_plexSub(10, languageCode: 'fre', codec: 'ass', selected: true)]);
|
||||||
|
final result = _svc(info: info).selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
|
expect(result.track.id, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declined forced intent honors the server's subtitles-off state", () {
|
||||||
|
// #1717 headline: the next episode has no forced track and no selected
|
||||||
|
// stream — the server's own decision (off) wins over the full track.
|
||||||
|
final tracks = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||||
|
final info = _info(subs: [_plexSub(10, languageCode: 'fre', codec: 'ass')]);
|
||||||
|
final result = _svc(info: info).selectSubtitleTrack(tracks, forcedIntent, null)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
|
expect(result.track.id, 'no');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a servable intent stays pending until its native track arrives', () {
|
||||||
|
final info = _info(
|
||||||
|
subs: [
|
||||||
|
_plexSub(10, languageCode: 'fre', codec: 'ass'),
|
||||||
|
_plexSub(11, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final service = _svc(info: info);
|
||||||
|
final arrived = [_sub('1', lang: 'fre', codec: 'ass')];
|
||||||
|
|
||||||
|
expect(service.selectSubtitleTrack(arrived, forcedIntent, null), isNull);
|
||||||
|
|
||||||
|
final complete = [...arrived, _sub('2', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||||
|
final resolved = service.selectSubtitleTrack(complete, forcedIntent, null)!;
|
||||||
|
expect(resolved.priority, TrackSelectionPriority.navigation);
|
||||||
|
expect(resolved.track.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deadline pass resolves a pending intent through the ladder', () {
|
||||||
|
final info = _info(
|
||||||
|
subs: [
|
||||||
|
_plexSub(10, languageCode: 'fre', codec: 'ass', selected: true),
|
||||||
|
_plexSub(11, languageCode: 'fre', title: 'FR Forced', codec: 'ass'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final arrived = [_sub('1', lang: 'fre', codec: 'ass', isDefault: true)];
|
||||||
|
final result = _svc(info: info).selectSubtitleTrack(arrived, forcedIntent, null, waitForPendingSource: false)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
|
expect(result.track.id, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full intent does not grab a forced-only catalog (symmetric decline)', () {
|
||||||
|
const fullIntent = SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'ass'),
|
||||||
|
);
|
||||||
|
final tracks = [_sub('1', lang: 'fre', title: 'FR Forced', codec: 'ass')];
|
||||||
|
final result = _svc().selectSubtitleTrack(tracks, fullIntent, null)!;
|
||||||
|
expect(result.priority, TrackSelectionPriority.off);
|
||||||
|
expect(result.track.id, 'no');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('container-sidecar ordinal fallback', () {
|
group('container-sidecar ordinal fallback', () {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/media/media_source_info.dart';
|
||||||
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
|
import 'package:plezy/utils/subtitle_forced_semantics.dart';
|
||||||
|
|
||||||
|
MediaSubtitleTrack _row({bool forced = false, String? title, String? displayTitle}) =>
|
||||||
|
MediaSubtitleTrack(id: 1, selected: false, forced: forced, title: title, displayTitle: displayTitle);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('MediaSubtitleTrack.effectiveForced', () {
|
||||||
|
test('flag alone qualifies', () {
|
||||||
|
expect(_row(forced: true).effectiveForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title alone qualifies', () {
|
||||||
|
expect(_row(title: 'FR Forced').effectiveForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displayTitle alone qualifies', () {
|
||||||
|
expect(_row(displayTitle: 'Français (Forced SRT)').effectiveForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neither flag nor title qualifies', () {
|
||||||
|
expect(_row(title: 'French', displayTitle: 'Français (SRT)').effectiveForced, isFalse);
|
||||||
|
expect(_row().effectiveForced, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SubtitleTrack.effectiveForced', () {
|
||||||
|
test('flag alone qualifies', () {
|
||||||
|
expect(const SubtitleTrack(id: '1', isForced: true).effectiveForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title alone qualifies', () {
|
||||||
|
expect(const SubtitleTrack(id: '1', title: 'FR Forced [ASS]').effectiveForced, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain title does not qualify', () {
|
||||||
|
expect(const SubtitleTrack(id: '1', title: 'French').effectiveForced, isFalse);
|
||||||
|
expect(const SubtitleTrack(id: '1').effectiveForced, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,24 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('titleSaysForced', () {
|
||||||
|
test('matches the forced token anywhere in the title', () {
|
||||||
|
expect(titleSaysForced('Forced'), isTrue);
|
||||||
|
expect(titleSaysForced('FORCED'), isTrue);
|
||||||
|
expect(titleSaysForced('FR Forced [ASS]'), isTrue);
|
||||||
|
expect(titleSaysForced('French (Forced)'), isTrue);
|
||||||
|
expect(titleSaysForced('forced.eng'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires a whole token, not a substring', () {
|
||||||
|
expect(titleSaysForced('unforced'), isFalse);
|
||||||
|
expect(titleSaysForced('Reinforced'), isFalse);
|
||||||
|
expect(titleSaysForced('French'), isFalse);
|
||||||
|
expect(titleSaysForced(''), isFalse);
|
||||||
|
expect(titleSaysForced(null), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('resolveTrackLanguageDisplay', () {
|
group('resolveTrackLanguageDisplay', () {
|
||||||
test('resolves 2-letter, 3-letter, and bibliographic codes', () {
|
test('resolves 2-letter, 3-letter, and bibliographic codes', () {
|
||||||
expect(resolveTrackLanguageDisplay(language: 'en'), 'English');
|
expect(resolveTrackLanguageDisplay(language: 'en'), 'English');
|
||||||
|
|||||||
Reference in New Issue
Block a user