From daab4f1e245e878f6a6b5666c7a67641e06d8441 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:46:10 +0200 Subject: [PATCH] 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 --- .../parts/episode_navigation.dart | 29 ++- lib/screens/video_player/parts/lifecycle.dart | 4 +- .../video_player/parts/playback_open.dart | 8 +- .../video_player/parts/playback_start.dart | 4 +- lib/screens/video_player_screen.dart | 44 ++-- lib/services/jellyfin_client.dart | 1 + .../jellyfin_client/parts/playback.dart | 54 ++-- .../playback_initialization_types.dart | 3 +- lib/services/playback_subtitle_resolver.dart | 100 ++++---- lib/services/subtitle_preference.dart | 131 ++++++++++ lib/services/track_manager.dart | 5 +- lib/services/track_selection_service.dart | 139 ++++++++--- lib/utils/subtitle_forced_semantics.dart | 16 ++ lib/utils/track_label_builder.dart | 11 +- .../player_initialization_lifecycle_test.dart | 38 ++- test/services/jellyfin_client_urls_test.dart | 35 ++- .../playback_subtitle_resolver_test.dart | 148 ++++++++--- test/services/subtitle_preference_test.dart | 84 +++++++ test/services/track_manager_test.dart | 7 +- .../track_selection_service_test.dart | 230 +++++++++++++++++- .../utils/subtitle_forced_semantics_test.dart | 43 ++++ test/utils/track_label_builder_test.dart | 18 ++ 22 files changed, 921 insertions(+), 231 deletions(-) create mode 100644 lib/services/subtitle_preference.dart create mode 100644 lib/utils/subtitle_forced_semantics.dart create mode 100644 test/services/subtitle_preference_test.dart create mode 100644 test/utils/subtitle_forced_semantics_test.dart diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 8a1af2e6..db3d088d 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -14,7 +14,7 @@ Future deferTranscodeSubtitleSelection({ required bool Function() shouldContinue, }) async { final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar); - trackManager.preferredSubtitleTrack = deferredTrack; + trackManager.preferredSubtitleTrack = SubtitlePreference.track(deferredTrack); // Persist first: the screen callback routes to onSubtitleTrackSelectedByUser, // 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. @@ -248,10 +248,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId; final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls()); final preferredSubtitleTrackForReload = newSubtitleChoice == null - ? _playbackSession?.subtitleSelection.primaryTrack + ? SubtitlePreference.trackOrNull(_playbackSession?.subtitleSelection.primaryTrack) : newSubtitleChoice.isOff - ? SubtitleTrack.off - : PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!); + ? const SubtitlePreference.off() + : SubtitlePreference.trackOrNull( + PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!), + ); final effectiveMediaSourceId = newMediaIndex != null ? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId : _requestedMediaSourceId; @@ -417,9 +419,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { Duration? resumePosition, bool preserveCurrentTrackSelection = false, AudioTrack? preservedAudioTrack, - SubtitleTrack? preservedSubtitleTrack, - SubtitleTrack? preservedSecondarySubtitleTrack, - SubtitleTrack? preferredSubtitleTrackOverride, + SubtitlePreference? preservedSubtitleTrack, + SubtitlePreference? preservedSecondarySubtitleTrack, + SubtitlePreference? preferredSubtitleTrackOverride, bool startPaused = false, bool useCurrentAudioStreamSelection = true, bool showErrorUi = true, @@ -473,9 +475,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { : null; final currentSubtitleTrack = preferredSubtitleTrackOverride ?? - (preserveCurrentTrackSelection ? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle : null); + (preserveCurrentTrackSelection + ? preservedSubtitleTrack ?? SubtitlePreference.trackOrNull(currentPlayer.state.track.subtitle) + : null); final currentSecondarySubtitleTrack = preserveCurrentTrackSelection - ? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle + ? preservedSecondarySubtitleTrack ?? + SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle) : null; final wasPlayingBeforeReload = _playbackIntentShouldPlay; var didOpenReplacement = false; @@ -545,7 +550,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { (selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId); final initializationSubtitleTrack = preservesRequestedSubtitleSource ? currentSubtitleTrack - : PlaybackSubtitleResolver.preferenceWithoutSourceIdentity(currentSubtitleTrack); + : SubtitlePreference.demoteToIntent(currentSubtitleTrack); try { // Eager identity-only: the loading UI shows the new title immediately, // while the selection/source state flips with the session commit at @@ -757,8 +762,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { plexClient: plexClient, getProfileSettings: () => userProfileProvider.profileSettings, preferredAudioTrack: currentAudioTrack, - preferredSubtitleTrack: subtitleSelection.primaryTrack, - preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack, + preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack), + preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack), ); _trackManager = trackManager; trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen); diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 1b0fc544..7061b4f4 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -319,8 +319,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { resumePosition: resumePosition, preserveCurrentTrackSelection: true, preservedAudioTrack: audioTrack, - preservedSubtitleTrack: subtitleTrack, - preservedSecondarySubtitleTrack: secondarySubtitleTrack, + preservedSubtitleTrack: SubtitlePreference.trackOrNull(subtitleTrack), + preservedSecondarySubtitleTrack: SubtitlePreference.trackOrNull(secondarySubtitleTrack), startPaused: true, reason: 'TV background suspend restore', ); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 9e72e475..1dd2e690 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -83,8 +83,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required MediaItem metadata, required PlaybackInitializationResult result, AudioTrack? preferredAudioTrack, - SubtitleTrack? preferredSubtitleTrack, - SubtitleTrack? preferredSecondarySubtitleTrack, + SubtitlePreference? preferredSubtitleTrack, + SubtitlePreference? preferredSecondarySubtitleTrack, bool preserveSubtitleSourceIdentity = true, }) async { await _waitForProfileSettingsIfNeeded(); @@ -440,8 +440,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required PlexClient? plexClient, required MediaServerUserProfile? Function() getProfileSettings, AudioTrack? preferredAudioTrack, - SubtitleTrack? preferredSubtitleTrack, - SubtitleTrack? preferredSecondarySubtitleTrack, + SubtitlePreference? preferredSubtitleTrack, + SubtitlePreference? preferredSecondarySubtitleTrack, }) { return TrackManager( player: forPlayer, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 38100932..89fd99f6 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -346,8 +346,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { plexClient: mediaClient is PlexClient ? mediaClient : null, getProfileSettings: () => context.read().profileSettings, preferredAudioTrack: _preferredAudioTrack, - preferredSubtitleTrack: subtitleSelection.primaryTrack, - preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack, + preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack), + preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack), ); // Store only the active sidecars for re-use after backend fallback. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 777d9645..f503789c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -62,6 +62,7 @@ import '../services/display_mode_service.dart'; import '../services/media_control_router.dart'; import '../services/settings_service.dart'; import '../services/sleep_timer_service.dart'; +import '../services/subtitle_preference.dart'; import '../services/track_manager.dart'; import '../services/track_selection_service.dart'; import '../services/ambient_lighting_service.dart'; @@ -137,39 +138,27 @@ bool shouldAutoStartReloadedMedia({ /// Builds an item-agnostic subtitle preference for an episode replacement. /// /// 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 -/// fallback for sessions created before source-backed selection was recorded. -SubtitleTrack? subtitlePreferenceForItemChange({ +/// committed semantic choice — a [SubtitleIntent] — may cross the item +/// boundary; native state is a fallback for sessions created before +/// source-backed selection was recorded. +SubtitlePreference? subtitlePreferenceForItemChange({ required bool hasCommittedSelection, required SubtitleTrack? committedTrack, required SubtitleTrack? nativeTrack, }) { - SubtitleTrack? normalize(SubtitleTrack? track, {required bool preserveOff}) { + SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) { 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 = - (track.title?.isNotEmpty ?? false) || - (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, - ); + final intent = SubtitleIntent.fromTrack(track); + return intent == null ? null : SubtitlePreference.intent(intent); } if (!hasCommittedSelection) { return normalize(nativeTrack, preserveOff: true); } - if (committedTrack == null) return SubtitleTrack.off; + if (committedTrack == null) return const SubtitlePreference.off(); final committedPreference = normalize(committedTrack, preserveOff: true); if (committedPreference != null) return committedPreference; @@ -377,8 +366,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin late TranscodeQualityPreset _selectedQualityPreset; int? _selectedAudioStreamId; AudioTrack? _preferredAudioTrack; - SubtitleTrack? _preferredSubtitleTrack; - SubtitleTrack? _preferredSecondarySubtitleTrack; + SubtitlePreference? _preferredSubtitleTrack; + SubtitlePreference? _preferredSecondarySubtitleTrack; bool _serverSupportsTranscoding = false; // 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) @@ -788,8 +777,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _playbackTranscodeSessionId = generateSessionIdentifier(); _selectedAudioStreamId = widget.selectedAudioStreamId; _preferredAudioTrack = widget.preferredAudioTrack; - _preferredSubtitleTrack = widget.preferredSubtitleTrack; - _preferredSecondarySubtitleTrack = widget.preferredSecondarySubtitleTrack; + _preferredSubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSubtitleTrack); + _preferredSecondarySubtitleTrack = SubtitlePreference.trackOrNull(widget.preferredSecondarySubtitleTrack); _selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original; _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); @@ -811,10 +800,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); } if (_preferredSubtitleTrack != null) { - final subtitleDesc = _preferredSubtitleTrack!.id == "no" - ? "OFF" - : "${_preferredSubtitleTrack!.title ?? _preferredSubtitleTrack!.id} (${_preferredSubtitleTrack!.language ?? "unknown"})"; - appLogger.d('Preferred subtitle track: $subtitleDesc'); + appLogger.d('Preferred subtitle track: $_preferredSubtitleTrack'); } try { diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 7e3dbbf0..d1e8d078 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -62,6 +62,7 @@ import 'jellyfin_playback_urls.dart'; import 'jellyfin_trickplay_service.dart'; import 'playback_initialization_types.dart'; import 'scrub_preview_source.dart'; +import 'subtitle_preference.dart'; import 'track_selection_service.dart'; import '../mpv/mpv.dart'; diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 68775467..474aa177 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -298,41 +298,31 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null; } - int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) { - if (preferred == null) return null; - if (preferred.id == SubtitleTrack.off.id) return -1; - - var semanticPreference = preferred; - const sourcePrefix = 'source:'; - if (preferred.id.startsWith(sourcePrefix)) { - semanticPreference = SubtitleTrack( - id: 'navigation', - title: preferred.title, - language: preferred.language, - codec: preferred.codec, - isDefault: preferred.isDefault, - isForced: preferred.isForced, - isExternal: preferred.isExternal, - isContainer: preferred.isContainer, - ); - final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length)); - if (explicit != null) { - final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull; - if (exactSource != null) { - // 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; + int? _validJellyfinSubtitleStreamId(SubtitlePreference? preferred, MediaSourceInfo mediaInfo) { + switch (preferred) { + case null: + return null; + case SubtitleOffPreference(): + return -1; + case SubtitleIntentPreference(:final intent): + return findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id; + case SubtitleTrackPreference(:final track): + const sourcePrefix = 'source:'; + final intent = SubtitleIntent.fromTrack(track); + if (track.id.startsWith(sourcePrefix)) { + final explicit = int.tryParse(track.id.substring(sourcePrefix.length)); + if (explicit != null && mediaInfo.subtitleTracks.any((row) => row.id == explicit)) { + // A source id is authoritative only within one item. When semantic + // metadata is available, re-derive the row through the hard-gated + // intent match so a reused stream index cannot cross language or + // forced-ness classes (#1716). + final hasLanguage = intent?.language?.isNotEmpty ?? false; + if (!hasLanguage) return explicit; + return findSourceTrackForIntent(intent!, mediaInfo.subtitleTracks)?.id; } - return semanticMatch?.id; } - } + return intent == null ? null : findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id; } - - return findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks)?.id; } Map? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) { diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 1ca83374..2e0cad00 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -6,6 +6,7 @@ import '../media/media_version.dart'; import '../models/audio_quality_preset.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/mpv.dart'; +import 'subtitle_preference.dart'; /// Inputs for [MediaServerClient.getPlaybackInitialization]. Most fields /// 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 /// embedded subtitles in the rendition can use this during negotiation; /// sidecar-capable backends keep subtitle delivery independent. - final SubtitleTrack? preferredSubtitleTrack; + final SubtitlePreference? preferredSubtitleTrack; /// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode. final String? sessionIdentifier; diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index bd7e14c4..4b519e93 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -4,7 +4,9 @@ import '../media/media_item.dart'; import '../media/media_server_user_profile.dart'; import '../media/media_source_info.dart'; import '../mpv/mpv.dart'; +import '../utils/subtitle_forced_semantics.dart'; import 'playback_initialization_types.dart'; +import 'subtitle_preference.dart'; import 'track_selection_service.dart'; /// A source-catalog subtitle choice. @@ -84,53 +86,57 @@ class PlaybackSubtitleSelection { class PlaybackSubtitleResolver { const PlaybackSubtitleResolver._(); - /// Removes a per-source ID while retaining the semantic identity needed to - /// carry a subtitle choice across items or media sources. - 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, + static SubtitlePreference? _sourceBackedPreference( + SubtitlePreference? preferred, MediaSourceInfo? mediaInfo, List<_SubtitleCandidate> candidates, { required bool preserveSourceIdentity, }) { - if (preferred == null || preferred.id == SubtitleTrack.off.id) return preferred; - - var semanticPreference = preferred; - if (preferred.id.startsWith('source:')) { - if (preserveSourceIdentity) { - 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)!; + SubtitlePreference resolveIntent(SubtitleIntentPreference preference) { + final row = findSourceTrackForIntent(preference.intent, mediaInfo?.subtitleTracks ?? const []); + if (row != null) { + for (final candidate in candidates) { + if (candidate.sourceStreamId == row.id) return SubtitlePreference.track(candidate.track); + } } + // Keep the declined intent: selectSubtitleTrack's intent branch and the + // priority ladder decide (falling to the server's own selection). + return preference; } - final sourceMatch = findPlexTrackForMpvSubtitle( - semanticPreference, - mediaInfo?.subtitleTracks ?? const [], - ); - if (sourceMatch == null) return semanticPreference; - - for (final candidate in candidates) { - if (candidate.sourceStreamId == sourceMatch.id) return candidate.track; + // Crossing an item/source boundary strips identity from every reference. + final pref = preserveSourceIdentity ? preferred : SubtitlePreference.demoteToIntent(preferred); + switch (pref) { + case null || SubtitleOffPreference(): + return pref; + case SubtitleIntentPreference(): + return resolveIntent(pref); + case SubtitleTrackPreference(:final track): + if (track.id.startsWith('source:')) { + final sourceStreamId = int.tryParse(track.id.substring('source:'.length)); + final exactCandidate = candidates + .where((candidate) => candidate.sourceStreamId == sourceStreamId) + .firstOrNull; + if (exactCandidate != null) return SubtitlePreference.track(exactCandidate.track); + // The row vanished from this source: the id is stale, so only its + // semantics may carry forward (hard-gated, unlike the old fuzzy + // rematch — a class-crossing lookalike must not inherit the id). + final demoted = SubtitlePreference.demoteToIntent(pref); + return demoted is SubtitleIntentPreference ? resolveIntent(demoted) : demoted; + } + // Same-item raw native/uri reference: identity-match it back to this + // item's own rows so the selection is source-backed where possible. + final sourceMatch = findPlexTrackForMpvSubtitle( + track, + mediaInfo?.subtitleTracks ?? const [], + ); + 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({ @@ -139,8 +145,8 @@ class PlaybackSubtitleResolver { required List sidecars, MediaServerUserProfile? profileSettings, AudioTrack? preferredAudioTrack, - SubtitleTrack? preferredSubtitleTrack, - SubtitleTrack? preferredSecondarySubtitleTrack, + SubtitlePreference? preferredSubtitleTrack, + SubtitlePreference? preferredSecondarySubtitleTrack, bool preserveSourceIdentity = true, }) { final candidates = <_SubtitleCandidate>[]; @@ -199,8 +205,12 @@ class PlaybackSubtitleResolver { candidates, preserveSourceIdentity: preserveSourceIdentity, ); - if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) { - final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference); + if (secondaryPreference != null && secondaryPreference is! SubtitleOffPreference) { + final secondary = switch (secondaryPreference) { + SubtitleOffPreference() => null, + SubtitleTrackPreference(:final track) => service.findBestSubtitleMatch(availableTracks, track), + SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, availableTracks), + }; secondaryCandidate = candidates .where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id) .firstOrNull; @@ -233,7 +243,9 @@ class PlaybackSubtitleResolver { language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language, codec: playable?.codec ?? sourceTrack.codec, 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, isContainer: playable?.isContainer ?? false, uri: playable?.uri, diff --git a/lib/services/subtitle_preference.dart b/lib/services/subtitle_preference.dart new file mode 100644 index 00000000..3742021c --- /dev/null +++ b/lib/services/subtitle_preference.dart @@ -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)'; +} diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index fd1c3d93..1340a804 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -8,6 +8,7 @@ import '../media/media_item.dart'; import '../media/media_server_user_profile.dart'; import '../media/media_source_info.dart'; import '../services/settings_service.dart'; +import '../services/subtitle_preference.dart'; import '../services/track_selection_service.dart'; import '../utils/app_logger.dart'; import '../utils/track_label_builder.dart'; @@ -50,8 +51,8 @@ class TrackManager { MediaItem metadata; MediaSourceInfo? mediaInfo; AudioTrack? preferredAudioTrack; - SubtitleTrack? preferredSubtitleTrack; - SubtitleTrack? preferredSecondarySubtitleTrack; + SubtitlePreference? preferredSubtitleTrack; + SubtitlePreference? preferredSecondarySubtitleTrack; // ── Internal state ───────────────────────────────────────────────── diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 80aa5298..586f7cc4 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -9,6 +9,8 @@ import '../media/media_source_info.dart'; import '../utils/future_extensions.dart'; import '../utils/app_logger.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, // 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); - if (mpvTrack.isForced == plexTrack.forced) { + if (mpvTrack.effectiveForced == plexTrack.effectiveForced) { score += 2; } @@ -130,7 +132,7 @@ bool _lowMetadataSubtitleFactsAreCompatible(SubtitleTrack mpvTrack, MediaSubtitl return false; } - return mpvTrack.isForced == plexTrack.forced; + return mpvTrack.effectiveForced == plexTrack.effectiveForced; } int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack) { @@ -152,7 +154,7 @@ int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack pl _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle) > 0) { score += 3; } - if (mpvTrack.isForced == plexTrack.forced) score += 2; + if (mpvTrack.effectiveForced == plexTrack.effectiveForced) score += 2; 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 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 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 AudioTrack? findMpvTrackForPlexAudio( MediaAudioTrack plexTrack, @@ -642,7 +692,7 @@ class TrackSelectionService { MediaServerUserProfile profile, { 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; final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false); @@ -675,7 +725,7 @@ class TrackSelectionService { SubtitleTrack? _findForcedSubtitleTrack(List availableTracks) { for (final track in availableTracks) { - if (track.isForced) return track; + if (track.effectiveForced) return track; } return null; } @@ -931,30 +981,57 @@ class TrackSelectionService { /// nothing at all. TrackSelectionResult? selectSubtitleTrack( List availableTracks, - SubtitleTrack? preferredSubtitleTrack, + SubtitlePreference? preference, AudioTrack? selectedAudioTrack, { bool waitForPendingSource = true, }) { - // Priority 1: Try preferred track from navigation - if (preferredSubtitleTrack != null) { - if (preferredSubtitleTrack.id == 'no') { + // Priority 1: the caller's preference — an identity reference into this + // item, or a semantic intent carried across an item boundary. + switch (preference) { + case null: + break; + case SubtitleOffPreference(): return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.navigation); - } else if (availableTracks.isNotEmpty) { - final subtitleToSelect = findBestSubtitleMatch(availableTracks, preferredSubtitleTrack); - if (subtitleToSelect != null) { - return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation); + case SubtitleTrackPreference(:final track): + if (track.id == 'no') { + return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.navigation); } - } - if (waitForPendingSource && preferredSubtitleTrack.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(preferredSubtitleTrack.id); - if (sourceTrack != null && !_hasCompleteDirectSourceCatalogFor(sourceTrack, availableTracks)) { - return null; + if (availableTracks.isNotEmpty) { + final subtitleToSelect = findBestSubtitleMatch(availableTracks, track); + if (subtitleToSelect != null) { + return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation); + } } - } + 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 @@ -1029,8 +1106,8 @@ class TrackSelectionService { /// Select and apply audio and subtitle tracks based on preferences Future selectAndApplyTracks({ AudioTrack? preferredAudioTrack, - SubtitleTrack? preferredSubtitleTrack, - SubtitleTrack? preferredSecondarySubtitleTrack, + SubtitlePreference? preferredSubtitleTrack, + SubtitlePreference? preferredSecondarySubtitleTrack, double? defaultPlaybackSpeed, Function(AudioTrack)? onAudioTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged, @@ -1113,11 +1190,17 @@ class TrackSelectionService { } // Apply preferred secondary subtitle track if provided (mpv-only) - if (preferredSecondarySubtitleTrack != null && - preferredSecondarySubtitleTrack.id != 'no' && + final secondaryPreference = preferredSecondarySubtitleTrack; + if (secondaryPreference != null && + secondaryPreference is! SubtitleOffPreference && player.supportsSecondarySubtitles && 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') { appLogger.d( 'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}', diff --git a/lib/utils/subtitle_forced_semantics.dart b/lib/utils/subtitle_forced_semantics.dart new file mode 100644 index 00000000..da7d9e52 --- /dev/null +++ b/lib/utils/subtitle_forced_semantics.dart @@ -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); +} diff --git a/lib/utils/track_label_builder.dart b/lib/utils/track_label_builder.dart index 36cdbdeb..dc73ae40 100644 --- a/lib/utils/track_label_builder.dart +++ b/lib/utils/track_label_builder.dart @@ -115,6 +115,11 @@ Set _subtitleCodecAliases(String? codec) { 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 { TrackLabelBuilder._(); @@ -161,7 +166,7 @@ class TrackLabelBuilder { techParts: [if (codec != null && codec.isNotEmpty) CodecUtils.formatSubtitleCodec(codec)], fallbackPrefix: 'Track', index: index, - forced: forced || _saysForced(cleanedTitle), + forced: forced || titleSaysForced(cleanedTitle), ); } @@ -195,7 +200,7 @@ class TrackLabelBuilder { primary = '$fallbackPrefix ${index + 1}'; } - if (forced && !_saysForced(primary)) { + if (forced && !titleSaysForced(primary)) { primary = '$primary (Forced)'; } @@ -203,8 +208,6 @@ class TrackLabelBuilder { 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 rawLanguageValues) { final normalized = title.trim().toLowerCase(); if (normalized == languageDisplay.trim().toLowerCase()) return true; diff --git a/test/screens/video_player/player_initialization_lifecycle_test.dart b/test/screens/video_player/player_initialization_lifecycle_test.dart index a6969144..0f419c6c 100644 --- a/test/screens/video_player/player_initialization_lifecycle_test.dart +++ b/test/screens/video_player/player_initialization_lifecycle_test.dart @@ -8,6 +8,7 @@ import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/screens/video_player_screen.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/subtitle_preference.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; @@ -59,14 +60,26 @@ void main() { nativeTrack: SubtitleTrack.off, ); - expect(result, isNotNull); - expect(result!.id, 'navigation'); - expect(result.title, committed.title); - expect(result.language, committed.language); - expect(result.codec, committed.codec); - expect(result.isForced, isTrue); - expect(result.isExternal, isTrue); - expect(result.uri, isNull); + expect(result, isA()); + final intent = (result! as SubtitleIntentPreference).intent; + expect(intent.title, committed.title); + expect(intent.language, committed.language); + expect(intent.codec, committed.codec); + expect(intent.forced, isTrue); + expect(intent.isExternal, isTrue); + }); + + 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()); + expect((result! as SubtitleIntentPreference).intent.forced, isTrue); }); test('item-change subtitle preference preserves committed off and empty secondary slots', () { @@ -76,7 +89,7 @@ void main() { committedTrack: SubtitleTrack.off, nativeTrack: const SubtitleTrack(id: '7', language: 'eng'), ), - same(SubtitleTrack.off), + const SubtitlePreference.off(), ); expect( subtitlePreferenceForItemChange( @@ -84,7 +97,7 @@ void main() { committedTrack: null, 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?.language, 'eng'); - expect(result?.uri, isNull); + expect(result, isA()); + expect((result! as SubtitleIntentPreference).intent.language, 'eng'); }); testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async { diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index afe42648..72812b0a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -17,6 +17,7 @@ import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/jellyfin_client.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/media_server_http_client.dart'; @@ -1060,7 +1061,7 @@ void main() { ); addTearDown(scoped.close); - Future initialize(SubtitleTrack preference) { + Future initialize(SubtitlePreference preference) { return scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( @@ -1082,31 +1083,41 @@ void main() { } 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); - 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); - 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); await initialize( - const SubtitleTrack( - id: 'source:3', - title: 'English Forced - SRT', - language: 'eng', - codec: 'srt', - isForced: true, + const SubtitlePreference.track( + SubtitleTrack(id: 'source:3', title: 'English Forced - SRT', language: 'eng', codec: 'srt', isForced: true), ), ); expectRequestedSubtitleIndex(5); - await initialize(SubtitleTrack.off); + await initialize(const SubtitlePreference.off()); 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); expect(result.playMethod, 'DirectStream'); diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index c58900db..92f41d21 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; +import 'package:plezy/services/subtitle_preference.dart'; import '../test_helpers/media_items.dart'; @@ -17,13 +18,14 @@ MediaSubtitleTrack _sourceSubtitle( bool external = false, bool usesExternalDelivery = false, String? key, + String? title, }) { return MediaSubtitleTrack( id: id, language: language, languageCode: language, codec: codec, - title: 'Subtitle $id', + title: title ?? 'Subtitle $id', selected: selected, forced: forced, // Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL @@ -229,7 +231,7 @@ void main() { metadata: metadata, mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]), sidecars: [_sidecar(2)], - preferredSubtitleTrack: SubtitleTrack.off, + preferredSubtitleTrack: const SubtitlePreference.off(), ); expect(result.isOff, isTrue); @@ -244,7 +246,7 @@ void main() { _sidecar(2, 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); @@ -266,7 +268,9 @@ void main() { _sidecar(2), _sidecar(3, language: 'swe'), ], - preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3), + preferredSubtitleTrack: SubtitlePreference.trackOrNull( + PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3), + ), ); expect(result.primarySourceStreamId, 3); @@ -301,7 +305,9 @@ void main() { _sidecar(2, 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); @@ -318,13 +324,15 @@ void main() { _sidecar(3), _sidecar(7, language: 'fra'), ], - preferredSubtitleTrack: const SubtitleTrack( - id: 'source:3', - title: 'French from the previous source', - language: 'fra', - codec: 'srt', - isExternal: true, - uri: 'https://example.test/previous/3.srt', + preferredSubtitleTrack: const SubtitlePreference.track( + SubtitleTrack( + id: 'source:3', + title: 'French from the previous source', + language: 'fra', + codec: 'srt', + isExternal: true, + uri: 'https://example.test/previous/3.srt', + ), ), preserveSourceIdentity: false, ); @@ -337,11 +345,8 @@ void main() { metadata: metadata, mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng'), _sourceSubtitle(7, language: 'spa', selected: true)]), sidecars: const [], - preferredSubtitleTrack: const SubtitleTrack( - id: 'source:3', - title: 'French from the previous source', - language: 'fra', - codec: 'srt', + preferredSubtitleTrack: const SubtitlePreference.track( + SubtitleTrack(id: 'source:3', title: 'French from the previous source', language: 'fra', codec: 'srt'), ), preserveSourceIdentity: false, ); @@ -360,12 +365,14 @@ void main() { _sidecar(7), _sidecar(9, language: 'fra'), ], - preferredSubtitleTrack: const SubtitleTrack( - id: 'navigation', - title: 'English from the previous episode', - language: 'eng', - codec: 'srt', - isExternal: true, + preferredSubtitleTrack: const SubtitlePreference.intent( + SubtitleIntent( + language: 'eng', + forced: false, + title: 'English from the previous episode', + codec: 'srt', + isExternal: true, + ), ), ); @@ -382,13 +389,14 @@ void main() { _sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true), ]), sidecars: [_sidecar(7), _sidecar(8)], - preferredSubtitleTrack: const SubtitleTrack( - id: 'navigation', - title: 'English forced from the previous episode', - language: 'eng', - codec: 'srt', - isForced: true, - isExternal: true, + preferredSubtitleTrack: const SubtitlePreference.intent( + SubtitleIntent( + language: 'eng', + forced: true, + title: 'English forced from the previous episode', + codec: 'srt', + isExternal: true, + ), ), ); @@ -397,6 +405,82 @@ void main() { 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', () { final result = PlaybackSubtitleResolver.resolve( metadata: metadata, @@ -433,7 +517,9 @@ void main() { _sidecar(2), _sidecar(3, language: 'swe'), ], - preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3), + preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull( + PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3), + ), ); expect(result.primarySourceStreamId, 2); diff --git a/test/services/subtitle_preference_test.dart b/test/services/subtitle_preference_test.dart new file mode 100644 index 00000000..236c8406 --- /dev/null +++ b/test/services/subtitle_preference_test.dart @@ -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()); + 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, + ); + }); + }); +} diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index b0b64bb5..e0496d19 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/mpv/player/player_stream_controllers.dart'; import 'package:plezy/screens/video_player_screen.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/subtitle_preference.dart'; import 'package:plezy/services/track_manager.dart'; import '../test_helpers/prefs.dart'; @@ -162,7 +163,7 @@ TrackManager _make({ metadata: metadata ?? _meta(), mediaInfo: mediaInfo, preferredAudioTrack: preferredAudioTrack, - preferredSubtitleTrack: preferredSubtitleTrack, + preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack), showMessage: showMessage, ); } @@ -660,7 +661,7 @@ void main() { ); expect(handledLocally, isTrue); - expect(mgr.preferredSubtitleTrack?.id, 'source:31'); + expect((mgr.preferredSubtitleTrack! as SubtitleTrackPreference).track.id, 'source:31'); expect(persistedTrack?.id, 'source:31'); expect(persistedSourceStreamId, 31); expect(player.selectedSubtitle, isEmpty); @@ -1266,7 +1267,7 @@ void main() { await mgr.invalidatePendingSelection(); mgr.preferredAudioTrack = audioTracks[1]; - mgr.preferredSubtitleTrack = subtitleTracks[1]; + mgr.preferredSubtitleTrack = SubtitlePreference.track(subtitleTracks[1]); final replacementApplication = mgr.applyTrackSelection(); await _drainAsync(); diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index 39d7cfdc..a8975b41 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -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/plex/plex_user_profile.dart'; import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/services/subtitle_preference.dart'; import 'package:plezy/services/track_selection_service.dart'; import '../test_helpers/media_items.dart'; @@ -453,14 +454,14 @@ void main() { group('selectSubtitleTrack', () { test('Priority 1: preferred id="no" forces subtitles off', () { 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.track.id, 'no'); }); test('Priority 1: preferred subtitle from navigation matches by language', () { 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.track.id, '2'); }); @@ -486,7 +487,7 @@ void main() { final preferredResult = service.selectSubtitleTrack( [nativeTrack], - const SubtitleTrack(id: 'source:20', codec: 'ass'), + const SubtitlePreference.track(SubtitleTrack(id: 'source:20', codec: 'ass')), null, )!; final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!; @@ -523,7 +524,7 @@ void main() { final preferredResult = service.selectSubtitleTrack( nativeTracks, - const SubtitleTrack(id: 'source:40', codec: 'ass'), + const SubtitlePreference.track(SubtitleTrack(id: 'source:40', codec: 'ass')), null, )!; final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!; @@ -540,7 +541,7 @@ void main() { final result = _svc( 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.track.id, 'native-default'); @@ -552,7 +553,11 @@ void main() { final service = _svc(info: _info(subs: plexTracks)); 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, ); expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull); @@ -607,8 +612,11 @@ void main() { ); final service = _svc(info: info); - expect(service.selectSubtitleTrack(const [first], preferred, null), isNull); - expect(service.selectSubtitleTrack(const [first, second], preferred, null)?.track.id, 'native-1'); + expect(service.selectSubtitleTrack(const [first], SubtitlePreference.track(preferred), null), isNull); + 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', () { @@ -634,7 +642,10 @@ void main() { 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', () { @@ -742,7 +753,9 @@ void main() { info: directPlayInfo(), ).selectSubtitleTrack( 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, ); @@ -756,7 +769,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), 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!.track.id, '1'); @@ -811,7 +824,7 @@ void main() { metadata: _meta(backend: MediaBackend.jellyfin), 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); @@ -878,6 +891,16 @@ void main() { 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', () { final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'jpn')]; final result = _svc( @@ -1026,6 +1049,189 @@ void main() { expect(findPlexTrackForMpvSubtitle(mpvForcedByName, plexTracks, allMpvTracks: allMpv)?.id, 30); 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', () { diff --git a/test/utils/subtitle_forced_semantics_test.dart b/test/utils/subtitle_forced_semantics_test.dart new file mode 100644 index 00000000..b5962bbe --- /dev/null +++ b/test/utils/subtitle_forced_semantics_test.dart @@ -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); + }); + }); +} diff --git a/test/utils/track_label_builder_test.dart b/test/utils/track_label_builder_test.dart index a42bec24..01361e34 100644 --- a/test/utils/track_label_builder_test.dart +++ b/test/utils/track_label_builder_test.dart @@ -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', () { test('resolves 2-letter, 3-letter, and bibliographic codes', () { expect(resolveTrackLanguageDisplay(language: 'en'), 'English');