From 2b3853a8822c22d87308ce0db0d34a08c808dcc3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:15:44 +0200 Subject: [PATCH] fix(player): preserve transcoded subtitles at high speed close #1622 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 72 +++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 1 + .../plezy/exoplayer/ExoPlayerPluginTest.kt | 25 ++ lib/mpv/models.dart | 3 + lib/mpv/models.freezed.dart | 39 +- lib/mpv/player/platform/player_android.dart | 3 +- lib/mpv/player/player_base.dart | 59 ++- lib/mpv/player/player_native.dart | 1 + .../parts/episode_navigation.dart | 62 ++- .../video_player/parts/playback_open.dart | 2 + .../jellyfin_client/parts/playback.dart | 21 +- .../playback_initialization_types.dart | 11 +- lib/services/playback_subtitle_resolver.dart | 108 ++++-- lib/services/plex_client.dart | 174 +++------ lib/services/track_manager.dart | 112 ++++-- lib/services/track_selection_service.dart | 155 +++++--- test/mpv/player_open_test.dart | 162 ++++++-- .../playback_subtitle_resolver_test.dart | 182 ++++++++- .../plex_playback_data_request_test.dart | 244 +++++++----- test/services/track_manager_test.dart | 358 ++++++++++++++++++ .../track_selection_service_test.dart | 175 ++++++++- 21 files changed, 1524 insertions(+), 445 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 99550e76..5543ab1e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -55,6 +55,9 @@ import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.FilteringMediaSource +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.mkv.MatroskaExtractor @@ -308,6 +311,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // External subtitles added dynamically private val externalSubtitles = mutableListOf() private val externalSubtitleUris = mutableListOf() + private val externalSubtitleContainerUris = mutableListOf() + private var playbackMediaSourceFactory: DefaultMediaSourceFactory? = null private var currentMediaUri: String? = null private var currentHeaders: Map? = null private var currentMediaIsLive: Boolean = false @@ -660,6 +665,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory) .setSubtitleParserFactory(assParserFactory) + playbackMediaSourceFactory = mediaSourceFactory // Wrap text renderers with subtitle delay support val wrappedRenderersFactory = RenderersFactory { eventHandler, videoListener, audioListener, textOutput, metadataOutput -> @@ -1215,7 +1221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction" ) - player.setMediaItem(buildMediaItem(uri), savedPosition) + setCurrentMediaSource(player, uri, savedPosition) player.prepare() player.playWhenReady = savedPlayWhenReady return true @@ -1626,6 +1632,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val isExternal = format.id?.startsWith("external_") == true val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] } + val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty() + val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal") @@ -1638,8 +1646,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), "forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0), "selected" to isSelected, - "external" to isExternal, - "external-filename" to externalUri + "external" to (isExternal || isContainer), + "container" to isContainer, + "external-filename" to (externalUri ?: containerUri) ) trackList.add(track) @@ -2337,6 +2346,34 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return mediaItemBuilder.build() } + private fun buildPlaybackMediaSource(uri: String): MediaSource? { + val factory = playbackMediaSourceFactory ?: return null + val primarySource = factory.createMediaSource(buildMediaItem(uri)) + if (externalSubtitleContainerUris.isEmpty()) return primarySource + + val sources = mutableListOf(primarySource) + externalSubtitleContainerUris.forEach { containerUri -> + val containerSource = factory.createMediaSource(MediaItem.fromUri(containerUri)) + sources.add(FilteringMediaSource(containerSource, C.TRACK_TYPE_TEXT)) + } + return MergingMediaSource( + /* adjustPeriodTimeOffsets = */ + true, + /* clipDurations = */ + false, + *sources.toTypedArray() + ) + } + + private fun setCurrentMediaSource(player: ExoPlayer, uri: String, positionMs: Long) { + val mediaSource = buildPlaybackMediaSource(uri) + if (mediaSource == null) { + player.setMediaItem(buildMediaItem(uri), positionMs) + } else { + player.setMediaSource(mediaSource, positionMs) + } + } + private fun selectedAudioFormat(): Format? { val player = exoPlayer ?: return null val selectedAudioGroup = player.currentTracks.groups.firstOrNull { @@ -2887,6 +2924,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { externalSubtitles.clear() externalSubtitleUris.clear() + externalSubtitleContainerUris.clear() lastSubtitleCues = emptyList() hadSelectedTextTrack = false audioTrackGroupMap.clear() @@ -2895,9 +2933,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { selectedSubtitleTrackId = null pendingDvTrackRestore = null - // Build external subtitle configurations (attached to MediaItem before prepare) - externalSubtitleList?.forEachIndexed { index, sub -> - val subUri = sub["uri"] as? String ?: return@forEachIndexed + // Build external subtitle sources before prepare. Container sidecars are + // filtered to text tracks and merged with the primary source; standalone + // subtitle files continue to use MediaItem subtitle configurations. + externalSubtitleList?.forEach { sub -> + val subUri = sub["uri"] as? String ?: return@forEach + if (subUri.isBlank()) return@forEach + if (sub["isContainer"] as? Boolean == true) { + if (!externalSubtitleContainerUris.contains(subUri)) { + externalSubtitleContainerUris.add(subUri) + } + return@forEach + } + + val index = externalSubtitles.size val title = sub["title"] as? String val language = sub["language"] as? String val codec = sub["codec"] as? String @@ -2940,10 +2989,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) emitSeekable(false, force = true) - val mediaItem = buildMediaItem(uri) - exoPlayer?.apply { - setMediaItem(mediaItem, startPositionMs) + setCurrentMediaSource(this, uri, startPositionMs) prepare() playWhenReady = autoPlay } @@ -3174,8 +3221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { clearTextOverrides = true ) - val mediaItem = buildMediaItem(uri) - player.setMediaItem(mediaItem, savedPosition) + setCurrentMediaSource(player, uri, savedPosition) player.prepare() player.playWhenReady = savedPlayWhenReady emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms") @@ -3317,8 +3363,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) selectedSubtitleTrackId = null - val mediaItem = buildMediaItem(mediaUri) - player.setMediaItem(mediaItem, savedPosition) + setCurrentMediaSource(player, mediaUri, savedPosition) player.prepare() player.playWhenReady = savedPlayWhenReady } else { @@ -3748,6 +3793,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { trackSelector = null httpDataSourceFactory = null dataSourceFactory = null + playbackMediaSourceFactory = null assHandler?.release() assHandler = null diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 1544feae..0fe6cb2c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -1258,6 +1258,7 @@ class ExoPlayerPlugin : val escapedUris = externalSubtitles.orEmpty() .mapNotNull { it["uri"] as? String } .filter { it.isNotEmpty() } + .distinct() .map(::escapeMpvPathListEntry) .toList() diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt index d5996342..03e00847 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt @@ -585,6 +585,31 @@ class ExoPlayerPluginTest { core.dispose() } + @Test + fun mpvFallbackLoadsSharedSubtitleContainerOnce() { + val plugin = ExoPlayerPlugin() + val options = mutableListOf() + val appendOptions = plugin.javaClass.getDeclaredMethod( + "appendExternalSubtitleOptions", + MutableList::class.java, + List::class.java + ).apply { + isAccessible = true + } + + appendOptions.invoke( + plugin, + options, + listOf( + mapOf("uri" to "shared.mkv", "isContainer" to true), + mapOf("uri" to "shared.mkv", "isContainer" to true), + mapOf("uri" to "") + ) + ) + + assertEquals(listOf("sub-files=%10%shared.mkv"), options) + } + @Test fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index 219072b6..917c0815 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -61,6 +61,7 @@ sealed class SubtitleTrack with _$SubtitleTrack { @Default(false) bool isDefault, @Default(false) bool isForced, @Default(false) bool isExternal, + @Default(false) bool isContainer, String? uri, }) = _SubtitleTrack; @@ -71,6 +72,7 @@ sealed class SubtitleTrack with _$SubtitleTrack { String? codec, bool isDefault = false, bool isForced = false, + bool isContainer = false, }) => SubtitleTrack( id: 'external:$uri', title: title, @@ -79,6 +81,7 @@ sealed class SubtitleTrack with _$SubtitleTrack { isDefault: isDefault, isForced: isForced, isExternal: true, + isContainer: isContainer, uri: uri, ); diff --git a/lib/mpv/models.freezed.dart b/lib/mpv/models.freezed.dart index c91ea6a1..18e1f721 100644 --- a/lib/mpv/models.freezed.dart +++ b/lib/mpv/models.freezed.dart @@ -789,7 +789,7 @@ as bool, /// @nodoc mixin _$SubtitleTrack { - String get id; String? get title; String? get language; String? get codec; bool get isDefault; bool get isForced; bool get isExternal; String? get uri; + String get id; String? get title; String? get language; String? get codec; bool get isDefault; bool get isForced; bool get isExternal; bool get isContainer; String? get uri; /// Create a copy of SubtitleTrack /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -800,16 +800,16 @@ $SubtitleTrackCopyWith get copyWith => _$SubtitleTrackCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.uri, uri) || other.uri == uri)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.isContainer, isContainer) || other.isContainer == isContainer)&&(identical(other.uri, uri) || other.uri == uri)); } @override -int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,uri); +int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,isContainer,uri); @override String toString() { - return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, uri: $uri)'; + return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, isContainer: $isContainer, uri: $uri)'; } @@ -820,7 +820,7 @@ abstract mixin class $SubtitleTrackCopyWith<$Res> { factory $SubtitleTrackCopyWith(SubtitleTrack value, $Res Function(SubtitleTrack) _then) = _$SubtitleTrackCopyWithImpl; @useResult $Res call({ - String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri + String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri }); @@ -837,7 +837,7 @@ class _$SubtitleTrackCopyWithImpl<$Res> /// Create a copy of SubtitleTrack /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? uri = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? isContainer = null,Object? uri = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable @@ -846,6 +846,7 @@ as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullabl as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable +as bool,isContainer: null == isContainer ? _self.isContainer : isContainer // ignore: cast_nullable_to_non_nullable as bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as String?, )); @@ -929,10 +930,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SubtitleTrack() when $default != null: -return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);case _: +return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);case _: return orElse(); } @@ -950,10 +951,10 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri) $default,) {final _that = this; switch (_that) { case _SubtitleTrack(): -return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);} +return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);} } /// A variant of `when` that fallback to returning `null` /// @@ -967,10 +968,10 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri)? $default,) {final _that = this; switch (_that) { case _SubtitleTrack() when $default != null: -return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);case _: +return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);case _: return null; } @@ -982,7 +983,7 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault, class _SubtitleTrack extends SubtitleTrack { - const _SubtitleTrack({required this.id, this.title, this.language, this.codec, this.isDefault = false, this.isForced = false, this.isExternal = false, this.uri}): super._(); + const _SubtitleTrack({required this.id, this.title, this.language, this.codec, this.isDefault = false, this.isForced = false, this.isExternal = false, this.isContainer = false, this.uri}): super._(); @override final String id; @@ -992,6 +993,7 @@ class _SubtitleTrack extends SubtitleTrack { @override@JsonKey() final bool isDefault; @override@JsonKey() final bool isForced; @override@JsonKey() final bool isExternal; +@override@JsonKey() final bool isContainer; @override final String? uri; /// Create a copy of SubtitleTrack @@ -1004,16 +1006,16 @@ _$SubtitleTrackCopyWith<_SubtitleTrack> get copyWith => __$SubtitleTrackCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.uri, uri) || other.uri == uri)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.isContainer, isContainer) || other.isContainer == isContainer)&&(identical(other.uri, uri) || other.uri == uri)); } @override -int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,uri); +int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,isContainer,uri); @override String toString() { - return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, uri: $uri)'; + return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, isContainer: $isContainer, uri: $uri)'; } @@ -1024,7 +1026,7 @@ abstract mixin class _$SubtitleTrackCopyWith<$Res> implements $SubtitleTrackCopy factory _$SubtitleTrackCopyWith(_SubtitleTrack value, $Res Function(_SubtitleTrack) _then) = __$SubtitleTrackCopyWithImpl; @override @useResult $Res call({ - String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri + String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri }); @@ -1041,7 +1043,7 @@ class __$SubtitleTrackCopyWithImpl<$Res> /// Create a copy of SubtitleTrack /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? uri = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? isContainer = null,Object? uri = freezed,}) { return _then(_SubtitleTrack( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable @@ -1050,6 +1052,7 @@ as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullabl as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable +as bool,isContainer: null == isContainer ? _self.isContainer : isContainer // ignore: cast_nullable_to_non_nullable as bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as String?, )); diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index aaa1dbe2..37512204 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -180,7 +180,7 @@ class PlayerAndroid extends PlayerBase { 'isLive': isLive, if (externalSubtitles != null && externalSubtitles.isNotEmpty) 'externalSubtitles': externalSubtitles - .where((s) => s.uri != null) + .where((s) => s.uri?.isNotEmpty == true) .map( (s) => { 'uri': s.uri, @@ -189,6 +189,7 @@ class PlayerAndroid extends PlayerBase { 'codec': s.codec, 'isDefault': s.isDefault, 'isForced': s.isForced, + 'isContainer': s.isContainer, }, ) .toList(), diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 87ff9068..5a59cb84 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -72,7 +72,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Duration? _timelineDuration; int _nextPropId = 0; final Map _propIdToName = {}; - Map _externalSubtitleMetadataByUri = const {}; + Map> _externalSubtitleMetadataByUri = const {}; bool _primaryMediaLoadStarted = false; bool _primaryMediaReadyEmitted = false; @@ -499,6 +499,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final subtitleTracks = []; String? selectedAudioId; String? selectedSubtitleId; + final containerMetadataIndexes = {}; for (final track in trackList) { if (track is! Map) continue; @@ -511,6 +512,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final selected = track['selected'] == true; if (type == 'audio') { + final rawExternalFilename = track['external-filename']; + final externalFilename = rawExternalFilename is String ? rawExternalFilename : null; + final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename]; + // Container sidecars are opened only to expose their subtitle tracks. + // Do not let their audio streams participate in normal track matching. + if (externalMetadata?.any((metadata) => metadata.isContainer) == true) continue; + if (selected) selectedAudioId = id; audioTracks.add( AudioTrack( @@ -527,20 +535,43 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (selected) selectedSubtitleId = id; final rawCodec = track['codec']; final codec = rawCodec is String ? rawCodec : null; + final rawTitle = track['title']; + final rawLanguage = track['lang']; final rawExternalFilename = track['external-filename']; final externalFilename = rawExternalFilename is String ? rawExternalFilename : null; final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename]; - final rawTitle = track['title']; - final rawLanguage = track['lang']; + final isContainer = + track['container'] == true || externalMetadata?.any((metadata) => metadata.isContainer) == true; + SubtitleTrack? matchedMetadata; + if (externalMetadata != null && externalMetadata.isNotEmpty) { + if (isContainer && externalFilename != null) { + final metadataIndex = containerMetadataIndexes[externalFilename] ?? 0; + containerMetadataIndexes[externalFilename] = metadataIndex + 1; + if (metadataIndex < externalMetadata.length && externalMetadata[metadataIndex].isContainer) { + matchedMetadata = externalMetadata[metadataIndex]; + } + } else { + matchedMetadata = externalMetadata.first; + } + } subtitleTracks.add( SubtitleTrack( id: id, - title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec), - language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null), - codec: externalMetadata?.codec ?? codec, - isDefault: externalMetadata?.isDefault ?? (track['default'] == true), - isForced: externalMetadata?.isForced ?? (track['forced'] == true), + // mpv may synthesize a container track title from the signed + // source filename. Source-catalog metadata is both safer and more + // accurate there, including on builds that drop disposition flags. + // Ordinary sidecars still fall back to metadata reported by mpv. + title: isContainer + ? matchedMetadata?.title + : matchedMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec), + language: isContainer + ? matchedMetadata?.language + : matchedMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null), + codec: matchedMetadata?.codec ?? codec, + isDefault: matchedMetadata?.isDefault ?? (track['default'] == true), + isForced: matchedMetadata?.isForced ?? (track['forced'] == true), isExternal: track['external'] == true, + isContainer: isContainer, uri: externalFilename, ), ); @@ -599,23 +630,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { @protected void setExternalSubtitleMetadata(List? externalSubtitles) { - final metadataByUri = {}; + final metadataByUri = >{}; for (final subtitle in externalSubtitles ?? const []) { final uri = subtitle.uri; if (uri != null && uri.isNotEmpty) { - metadataByUri[uri] = subtitle; + (metadataByUri[uri] ??= []).add(subtitle); } } _externalSubtitleMetadataByUri = metadataByUri; } @protected - Map snapshotExternalSubtitleMetadata() => - Map.of(_externalSubtitleMetadataByUri); + Map> snapshotExternalSubtitleMetadata() => + Map>.of(_externalSubtitleMetadataByUri); @protected - void restoreExternalSubtitleMetadata(Map snapshot) { - _externalSubtitleMetadataByUri = Map.of(snapshot); + void restoreExternalSubtitleMetadata(Map> snapshot) { + _externalSubtitleMetadataByUri = Map>.of(snapshot); } @protected diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 6c303c00..f3e4f6e7 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -124,6 +124,7 @@ class PlayerNative extends PlayerBase { ?.map((subtitle) => subtitle.uri) .whereType() .where((uri) => uri.isNotEmpty) + .toSet() .map((uri) => _escapePathListEntry(uri, separator)) .toList(); if (escapedUris == null || escapedUris.isEmpty) return null; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 2822d68d..a4e88b73 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -1,5 +1,25 @@ part of '../../video_player_screen.dart'; +/// Keeps an explicit transcode subtitle choice pending while the native +/// player finishes discovering sidecars that were attached during open. +/// +/// Returning true tells the source-switch caller that no media reload is +/// needed. [TrackManager] owns the generation-scoped late-track listener. +Future deferTranscodeSubtitleSelection({ + required TrackManager trackManager, + required MediaSubtitleTrack sourceTrack, + required PlaybackSubtitleSidecar sourceSidecar, + required int sourceStreamId, + required Future Function(SubtitleTrack track, {int? sourceStreamId}) onSubtitleTrackChanged, + required bool Function() shouldContinue, +}) async { + final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar); + trackManager.preferredSubtitleTrack = deferredTrack; + trackManager.applyTrackSelectionWhenReady(); + await onSubtitleTrackChanged(deferredTrack, sourceStreamId: sourceStreamId); + return shouldContinue(); +} + extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { void _clearEpisodeLoadingFlags() { if (!_isLoadingNext && !_isLoadingPrevious) return; @@ -198,7 +218,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (newSubtitleChoice != null && newMediaIndex == null && newPreset == null && newAudioStreamId == null) { try { - final selected = await _selectDirectPlaySourceSubtitleLocally( + final selected = await _selectSourceSubtitleLocally( currentPlayer, newSubtitleChoice, shouldContinue: isCurrentSourceSwitch, @@ -302,12 +322,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } } - Future _selectDirectPlaySourceSubtitleLocally( + Future _selectSourceSubtitleLocally( Player currentPlayer, PlaybackSourceSubtitleChoice choice, { required bool Function() shouldContinue, }) async { - if (_isTranscoding) return false; if (choice.isOff) { await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off); if (!shouldContinue()) return false; @@ -331,15 +350,30 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (sourceTrack == null) return false; final nativeTracks = currentPlayer.state.tracks.subtitle; - final nativeTrack = PlaybackSubtitleResolver.nativeTrackForDirectPlaySource( + final session = _playbackSession; + final sourceSidecar = session == null ? null : _sidecarForSourceStreamId(session, sourceStreamId); + final nativeTrack = PlaybackSubtitleResolver.nativeTrackForSource( sourceTrack: sourceTrack, nativeTracks: nativeTracks, allSourceTracks: info.subtitleTracks, - isResolvedSidecar: _sourceSubtitleSidecarIdsForControls().contains(sourceStreamId), - currentSourceStreamId: _playbackSession?.subtitleSelection.primarySourceStreamId, + isResolvedSidecar: sourceSidecar != null, + isContainerSidecar: sourceSidecar?.track.isContainer == true, + currentSourceStreamId: session?.subtitleSelection.primarySourceStreamId, selectedNativeTrack: currentPlayer.state.track.subtitle, ); - if (nativeTrack == null) return false; + if (nativeTrack == null) { + final trackManager = _trackManager; + if (!_isTranscoding || sourceSidecar == null || trackManager == null) return false; + + return deferTranscodeSubtitleSelection( + trackManager: trackManager, + sourceTrack: sourceTrack, + sourceSidecar: sourceSidecar, + sourceStreamId: sourceStreamId, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + shouldContinue: shouldContinue, + ); + } await currentPlayer.selectSubtitleTrack(nativeTrack); if (!shouldContinue()) return false; @@ -420,6 +454,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final previousMetadata = _currentMetadata; final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this); final previousPartId = _currentMediaInfo?.partId; + final previousMediaSourceId = _currentMediaInfo?.mediaSourceId; final previousHasFirstFrame = _hasFirstFrame.value; final previousHasRenderedFirstFrame = _hasRenderedFirstFrame; final previousHasFatalPlaybackError = _hasFatalPlaybackError; @@ -497,6 +532,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { isOffline: _offlineLibraryMode, routeKind: VideoPlayerRouteKind.vod, ); + final preservesRequestedSubtitleSource = + !isItemChange && + targetMediaIndex == _effectiveSelectedMediaIndex && + (selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId); + final initializationSubtitleTrack = preservesRequestedSubtitleSource + ? currentSubtitleTrack + : PlaybackSubtitleResolver.preferenceWithoutSourceIdentity(currentSubtitleTrack); try { // Eager identity-only: the loading UI shows the new title immediately, // while the selection/source state flips with the session commit at @@ -533,7 +575,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { offlineLibraryMode: _offlineLibraryMode, qualityPreset: targetQualityPreset, selectedAudioStreamId: targetAudioStreamId, - preferredSubtitleTrack: currentSubtitleTrack, + preferredSubtitleTrack: initializationSubtitleTrack, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, ); @@ -553,6 +595,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, + preserveSubtitleSourceIdentity: + result.mediaInfo != null && + ((previousMediaSourceId != null && previousMediaSourceId == result.mediaInfo!.mediaSourceId) || + (previousPartId != null && previousPartId == result.mediaInfo!.partId)), ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index dd55ff5a..9e72e475 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -85,6 +85,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, + bool preserveSubtitleSourceIdentity = true, }) async { await _waitForProfileSettingsIfNeeded(); if (!mounted) return const PlaybackSubtitleSelection.off(); @@ -97,6 +98,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, + preserveSourceIdentity: preserveSubtitleSourceIdentity, ); } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index eef776fc..861588bc 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -330,24 +330,37 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 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, reject a coincidentally reused episode id. - final exactMatch = findPlexTrackForMpvSubtitle(preferred, [exactSource]); + // 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 || (exactMatch != null && preferred.isForced == exactSource.forced)) { + if (!hasLanguage || (semanticMatch?.id == explicit && preferred.isForced == exactSource.forced)) { return explicit; } + return semanticMatch?.id; } } } - return findPlexTrackForMpvSubtitle(preferred, 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 f0526c73..c6a07eff 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -70,12 +70,15 @@ class PlaybackInitializationOptions { /// /// [sourceStreamId] links the playable URI back to the authoritative server /// subtitle catalog. It is nullable for legacy/offline files whose filename -/// cannot be mapped to cached stream metadata. +/// cannot be mapped to cached stream metadata. [preload] makes the sidecar +/// available before it is selected, allowing local track switches without a +/// media reload. class PlaybackSubtitleSidecar { final int? sourceStreamId; final SubtitleTrack track; + final bool preload; - const PlaybackSubtitleSidecar({required this.sourceStreamId, required this.track}); + const PlaybackSubtitleSidecar({required this.sourceStreamId, required this.track, this.preload = false}); } /// Reason the transcode branch fell back to direct play. @@ -93,8 +96,8 @@ class PlaybackInitializationResult { final String? videoUrl; final MediaSourceInfo? mediaInfo; - /// Complete sidecar catalog for this source. Callers must resolve the active - /// subtitle choice and attach only the selected sidecar(s) at open time. + /// Complete sidecar catalog for this source. Callers resolve the active + /// subtitle choice and also attach sidecars marked for preloading. final List subtitleSidecars; final bool isOffline; diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index c38b1f95..3f1ed4a6 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -41,6 +41,7 @@ class PlaybackSubtitleSelection { final SubtitleTrack? secondaryTrack; final int? secondarySourceStreamId; final PlaybackSubtitleSidecar? secondarySidecar; + final List preloadedSidecars; const PlaybackSubtitleSelection({ required this.primaryTrack, @@ -49,9 +50,10 @@ class PlaybackSubtitleSelection { this.secondaryTrack, this.secondarySourceStreamId, this.secondarySidecar, + this.preloadedSidecars = const [], }); - const PlaybackSubtitleSelection.off() + const PlaybackSubtitleSelection.off({this.preloadedSidecars = const []}) : primaryTrack = SubtitleTrack.off, primarySourceStreamId = null, primarySidecar = null, @@ -63,36 +65,72 @@ class PlaybackSubtitleSelection { List get sidecarsAtOpen { final tracks = []; - final primary = primarySidecar?.track; - if (primary != null) tracks.add(primary); - final secondary = secondarySidecar?.track; - if (secondary != null && secondary.uri != primary?.uri) tracks.add(secondary); + final added = {}; + void add(SubtitleTrack? track) { + if (track != null && added.add(track)) tracks.add(track); + } + + for (final sidecar in preloadedSidecars) { + add(sidecar.track); + } + add(primarySidecar?.track); + add(secondarySidecar?.track); return tracks; } } -/// Resolves the server subtitle catalog before opening the native player, so -/// only the active sidecar is part of the open operation. +/// Resolves the server subtitle catalog before opening the native player, +/// combining the active choice with any sidecars marked for preloading. 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, MediaSourceInfo? mediaInfo, - List<_SubtitleCandidate> candidates, - ) { + 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)!; + } + } + final sourceMatch = findPlexTrackForMpvSubtitle( - preferred, + semanticPreference, mediaInfo?.subtitleTracks ?? const [], ); - if (sourceMatch == null) return preferred; + if (sourceMatch == null) return semanticPreference; for (final candidate in candidates) { if (candidate.sourceStreamId == sourceMatch.id) return candidate.track; } - return preferred; + return semanticPreference; } static PlaybackSubtitleSelection resolve({ @@ -103,6 +141,7 @@ class PlaybackSubtitleResolver { AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, + bool preserveSourceIdentity = true, }) { final candidates = <_SubtitleCandidate>[]; final matchedSidecars = {}; @@ -128,6 +167,7 @@ class PlaybackSubtitleResolver { ); } + final preloadedSidecars = sidecars.where((sidecar) => sidecar.preload).toList(growable: false); final availableTracks = candidates.map((candidate) => candidate.track).toList(growable: false); final service = TrackSelectionService( profileSettings: profileSettings, @@ -135,16 +175,30 @@ class PlaybackSubtitleResolver { plexMediaInfo: mediaInfo, ); final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track; - final primaryPreference = _sourceBackedPreference(preferredSubtitleTrack, mediaInfo, candidates); + final primaryPreference = _sourceBackedPreference( + preferredSubtitleTrack, + mediaInfo, + candidates, + preserveSourceIdentity: preserveSourceIdentity, + ); final primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio); - final primary = primaryResult.track; - if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off(); + final primary = primaryResult?.track; + if (primary == null || primary.id == SubtitleTrack.off.id) { + return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars); + } final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull; - if (primaryCandidate == null) return const PlaybackSubtitleSelection.off(); + if (primaryCandidate == null) { + return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars); + } _SubtitleCandidate? secondaryCandidate; - final secondaryPreference = _sourceBackedPreference(preferredSecondarySubtitleTrack, mediaInfo, candidates); + final secondaryPreference = _sourceBackedPreference( + preferredSecondarySubtitleTrack, + mediaInfo, + candidates, + preserveSourceIdentity: preserveSourceIdentity, + ); if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) { final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference); secondaryCandidate = candidates @@ -159,6 +213,7 @@ class PlaybackSubtitleResolver { secondaryTrack: secondaryCandidate?.track, secondarySourceStreamId: secondaryCandidate?.sourceStreamId, secondarySidecar: secondaryCandidate?.sidecar, + preloadedSidecars: preloadedSidecars, ); } @@ -180,26 +235,29 @@ class PlaybackSubtitleResolver { isDefault: sourceTrack.selected, isForced: sourceTrack.forced, isExternal: playable != null, + isContainer: playable?.isContainer ?? false, uri: playable?.uri, ); } - /// Resolve a server source row to a track already loaded by direct play. - /// Resolved sidecars only match by their stable URL key (or the current - /// source identity); fuzzy language/codec matching must not select a - /// different sidecar that happens to have similar metadata. A server's - /// `external delivery` flag is not sufficient to classify a direct-play - /// row as a sidecar: Jellyfin applies it to embedded tracks that mpv still - /// discovers in the original file. - static SubtitleTrack? nativeTrackForDirectPlaySource({ + /// Resolve a server source row to a track already loaded by the player. + /// Standalone sidecars match only by their stable URL key (or current source + /// identity), while a container sidecar uses normal Plex/native metadata + /// matching across the subtitle tracks extracted from that container. + static SubtitleTrack? nativeTrackForSource({ required MediaSubtitleTrack sourceTrack, required List nativeTracks, required List allSourceTracks, required bool isResolvedSidecar, + required bool isContainerSidecar, int? currentSourceStreamId, SubtitleTrack? selectedNativeTrack, }) { if (isResolvedSidecar) { + if (isContainerSidecar) { + final containerTracks = nativeTracks.where((track) => track.isContainer).toList(growable: false); + return findMpvTrackForPlexSubtitle(sourceTrack, containerTracks, allPlexTracks: allSourceTracks); + } final key = sourceTrack.key; if (key != null && key.isNotEmpty) { for (final candidate in nativeTracks) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 86941a2b..b6e2adc7 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -77,7 +77,6 @@ import 'plex_lyrics_parser.dart'; import 'plex_mappers.dart'; import 'plex_playback_mapper.dart'; import 'playback_initialization_types.dart'; -import 'track_selection_service.dart'; part 'plex_client/parts/live_tv.dart'; part 'plex_client/parts/playlists.dart'; @@ -2437,9 +2436,9 @@ class PlexClient /// Build an HLS VOD transcode stream URL (decision + start path). /// - /// Text subtitles selected on the Plex part are segmented as WebVTT, - /// image subtitles are burned because HLS has no bitmap subtitle rendition, - /// and real external sidecars are still attached separately by callers. + /// Subtitle delivery stays outside the HLS video stream. Callers attach + /// Plex subtitle sources independently, so changing subtitle tracks never + /// restarts the video transcode. /// /// [transcodeSessionId] and [sessionIdentifier] should be reused across /// seeks + quality/version/audio switches within one playback so the @@ -2452,7 +2451,6 @@ class PlexClient required String sessionIdentifier, required String transcodeSessionId, int? audioStreamId, - MediaSubtitleTrack? selectedSubtitleTrack, }) async { try { final allParams = _buildTranscodeParams( @@ -2463,7 +2461,6 @@ class PlexClient sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, audioStreamId: audioStreamId, - selectedSubtitleTrack: selectedSubtitleTrack, ); return await _runTranscodeDecision( startEndpoint: _plexVideoHlsStartEndpoint, @@ -2521,38 +2518,31 @@ class PlexClient required Map allParams, required bool isOriginal, }) async { - final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision'; - final decisionClient = MediaServerHttpClient( - connectTimeout: MediaServerTimeouts.connect, - receiveTimeout: MediaServerTimeouts.receive, - defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'}, + final decisionResponse = await _http.get( + decisionEndpoint, + queryParameters: allParams, + headers: const {'Accept-Language': 'en', 'Accept': 'application/json'}, ); - try { - final decisionUrl = '${config.baseUrl}$decisionEndpoint?$queryString'; - final decisionResponse = await decisionClient.get(decisionUrl); - final decisionBody = decisionResponse.data?.toString() ?? ''; - appLogger.i( - 'Transcode decision [${decisionResponse.statusCode}] body: ' - '${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}', - ); + final decisionBody = decisionResponse.data?.toString() ?? ''; + appLogger.i( + 'Transcode decision [${decisionResponse.statusCode}] body: ' + '${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}', + ); - if (decisionResponse.statusCode != 200) { - appLogger.w('Transcode decision returned ${decisionResponse.statusCode}'); - return (startPath: null, outcome: TranscodeDecisionOutcome.failed); - } - - final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal); - if (outcome == TranscodeDecisionOutcome.failed) { - return (startPath: null, outcome: outcome); - } - - return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome); - } finally { - decisionClient.close(); + if (decisionResponse.statusCode != 200) { + appLogger.w('Transcode decision returned ${decisionResponse.statusCode}'); + return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } + + final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal); + if (outcome == TranscodeDecisionOutcome.failed) { + return (startPath: null, outcome: outcome); + } + + return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome); } String _buildTranscodeStartPathFromParams( @@ -2580,13 +2570,8 @@ class PlexClient required String sessionIdentifier, required String transcodeSessionId, int? audioStreamId, - MediaSubtitleTrack? selectedSubtitleTrack, }) { final isOriginal = preset.isOriginal; - final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack); - final segmentSubtitle = selectedInternalSubtitle != null && _canTranscodeSubtitleAsText(selectedInternalSubtitle); - final burnSubtitle = - selectedInternalSubtitle != null && CodecUtils.isImageSubtitleCodec(selectedInternalSubtitle.codec); final clientProfileExtra = _buildPlexHlsClientProfileExtra( maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null, ); @@ -2609,13 +2594,7 @@ class PlexClient 'directStreamAudio': '0', 'mediaBufferSize': '102400', 'session': transcodeSessionId, - 'subtitles': segmentSubtitle - ? 'segmented' - : burnSubtitle - ? 'burn' - : 'none', - if (selectedInternalSubtitle != null) 'subtitleStreamID': selectedInternalSubtitle.id.toString(), - if (segmentSubtitle) 'advancedSubtitles': 'text', + 'subtitles': 'none', if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(), 'Accept-Language': 'en', 'X-Plex-Session-Identifier': sessionIdentifier, @@ -2644,7 +2623,6 @@ class PlexClient required String sessionIdentifier, required String transcodeSessionId, int? audioStreamId, - MediaSubtitleTrack? selectedSubtitleTrack, }) { return _buildTranscodeParams( ratingKey: ratingKey, @@ -2654,7 +2632,6 @@ class PlexClient sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, audioStreamId: audioStreamId, - selectedSubtitleTrack: selectedSubtitleTrack, ); } @@ -3084,9 +3061,8 @@ class PlexClient /// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata, /// then either runs the transcode-decision flow or returns the direct-play - /// URL. Keyed subtitle tracks remain external sidecars; selected internal - /// text tracks become segmented WebVTT and image tracks are burned into the - /// HLS rendition. + /// URL. Transcoded video stays subtitle-free; every Plex subtitle remains + /// independently selectable through the returned sidecar catalog. @override Future getPlaybackInitialization(PlaybackInitializationOptions options) async { try { @@ -3135,7 +3111,6 @@ class PlexClient } final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo); - final selectedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack); final result = await buildTranscodeStartPath( ratingKey: options.metadata.id, mediaIndex: data.selectedMediaIndex, @@ -3144,12 +3119,11 @@ class PlexClient sessionIdentifier: options.sessionIdentifier!, transcodeSessionId: options.transcodeSessionId!, audioStreamId: resolvedAudioId, - selectedSubtitleTrack: selectedSubtitleTrack, ); if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) { final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token); - final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo); + final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo, data.videoUrl!); return PlaybackInitializationResult( availableVersions: data.availableVersions, videoUrl: transcodeUrl, @@ -3249,41 +3223,6 @@ class PlexClient return tracks.first.id; } - MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) { - if (info == null) return null; - for (final track in info.subtitleTracks) { - if (track.selected) return track; - } - return null; - } - - MediaSubtitleTrack? _resolveTranscodeSubtitleTrack(MediaSourceInfo? info, SubtitleTrack? preferred) { - if (info == null) return null; - if (preferred == null) return _selectedSubtitleTrack(info); - if (preferred.id == SubtitleTrack.off.id) return null; - - MediaSubtitleTrack? matched; - if (preferred.id.startsWith('source:')) { - final sourceId = int.tryParse(preferred.id.substring('source:'.length)); - if (sourceId != null) { - for (final track in info.subtitleTracks) { - if (track.id == sourceId) { - matched = track; - break; - } - } - } - } - - matched ??= findPlexTrackForMpvSubtitle(preferred, info.subtitleTracks); - return matched ?? _selectedSubtitleTrack(info); - } - - @visibleForTesting - MediaSubtitleTrack? resolveTranscodeSubtitleTrackForTesting(MediaSourceInfo? info, SubtitleTrack? preferred) { - return _resolveTranscodeSubtitleTrack(info, preferred); - } - /// Build the absolute URL for an external subtitle track on this Plex /// server. Returns `null` for tracks that aren't external (no `/library/ /// streams/{id}` key) or when the server has no auth token. @@ -3305,20 +3244,10 @@ class PlexClient /// `Stream.key` is required here. String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) { if (track.key == null || track.key!.isEmpty) return null; - final token = config.token; - if (token == null) return null; final ext = CodecUtils.getSubtitleExtension(track.codec); - return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token'; - } - - bool _canTranscodeSubtitleAsText(MediaSubtitleTrack track) { - return CodecUtils.isTextSubtitleCodec(track.codec); - } - - MediaSubtitleTrack? _selectedInternalSubtitleForHls(MediaSubtitleTrack? track) { - if (track == null) return null; - if (track.key != null && track.key!.isNotEmpty) return null; - return CodecUtils.isTranscodableSubtitleCodec(track.codec) ? track : null; + final url = '${config.baseUrl}${track.key}.$ext?encoding=utf-8'; + final token = config.token; + return token == null ? url : '$url&X-Plex-Token=$token'; } SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) { @@ -3334,21 +3263,42 @@ class PlexClient ); } - /// Build subtitle sidecars for Plex transcode playback. Keyed tracks remain - /// external; the selected internal track is delivered by the HLS rendition. - List _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { + SubtitleTrack _containerSubtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) { + return SubtitleTrack( + id: 'container:${track.id}', + title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}', + language: track.languageCode, + codec: track.codec, + isDefault: track.selected, + isForced: track.forced, + isExternal: true, + isContainer: true, + uri: url, + ); + } + + /// Build the complete subtitle catalog for Plex transcode playback. + /// + /// Real sidecar files keep their direct stream URL. Embedded subtitle + /// streams share the original media container as a subtitle-only source; + /// player backends filter that source to text tracks. Every entry is + /// preloaded so changing subtitles is a local track selection. + List _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo, String sourceUrl) { if (mediaInfo == null) return const []; - if (config.token == null) { - appLogger.w('No auth token available for transcode sidecar subtitles'); - return const []; - } final tracks = []; for (final sub in mediaInfo.subtitleTracks) { try { - final url = _buildSidecarSubtitleUrl(sub); - if (url == null) continue; - tracks.add(PlaybackSubtitleSidecar(sourceStreamId: sub.id, track: _subtitleTrackFromMediaTrack(sub, url))); + final directUrl = _buildSidecarSubtitleUrl(sub); + tracks.add( + PlaybackSubtitleSidecar( + sourceStreamId: sub.id, + track: directUrl == null + ? _containerSubtitleTrackFromMediaTrack(sub, sourceUrl) + : _subtitleTrackFromMediaTrack(sub, directUrl), + preload: true, + ), + ); } catch (e) { appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e); } @@ -3357,8 +3307,8 @@ class PlexClient } @visibleForTesting - List buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) { - return _buildTranscodeSidecarSubtitles(mediaInfo).map((sidecar) => sidecar.track).toList(growable: false); + List buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) { + return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl); } /// Build list of external subtitle tracks from media info diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index ef140ee0..c30e56fb 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -56,7 +56,6 @@ class TrackManager { bool waitingForExternalSubsTrackSelection = false; bool _externalSubtitleAddsInFlight = false; bool _isApplyingTrackSelection = false; - int? _applyingSelectionGeneration; Completer? _selectionIdleCompleter; Future? _activePlayerMutationDrain; List _lastExternalSubtitles = const []; @@ -199,31 +198,61 @@ class TrackManager { // ── Track selection ──────────────────────────────────────────────── /// Apply track selection once tracks are available. - /// If tracks are not yet loaded, subscribes to the stream. + /// + /// The five-second fallback applies any ready audio/rate settings, but a + /// source that advertises subtitles keeps listening for their late native + /// track-list update. The listener has a separate hard deadline and every + /// callback is scoped to the current media generation. void applyTrackSelectionWhenReady() { + final selectionGeneration = _selectionGeneration; + bool selectionIsCurrent() => _isSelectionCurrent(selectionGeneration); final currentTracks = player.state.tracks; if (_tracksReadyForSelection(currentTracks)) { - applyTrackSelection(); - } else { - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = player.streams.tracks.listen((tracks) { - if (!_tracksReadyForSelection(tracks)) return; - - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; - _trackSelectionFallbackTimer?.cancel(); - _trackSelectionFallbackTimer = null; - applyTrackSelection(); - }); - - _trackSelectionFallbackTimer?.cancel(); - _trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () { - if (!isActive()) return; - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; - applyTrackSelection(); - }); + unawaited(applyTrackSelection()); + return; } + + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = player.streams.tracks.listen((tracks) { + if (!selectionIsCurrent() || !_tracksReadyForSelection(tracks)) return; + + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = null; + unawaited(applyTrackSelection()); + }); + + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () { + if (!selectionIsCurrent()) return; + + final tracks = player.state.tracks; + final waitingForAdvertisedSubtitles = + mediaInfo?.subtitleTracks.isNotEmpty == true && !_tracksReadyForSelection(tracks); + if (!waitingForAdvertisedSubtitles) { + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _trackSelectionFallbackTimer = null; + unawaited(applyTrackSelection()); + return; + } + + appLogger.w( + 'Native subtitle tracks are still pending after 5 seconds; applying ready track settings and continuing to wait', + ); + unawaited(applyTrackSelection()); + _trackSelectionFallbackTimer = Timer(const Duration(seconds: 25), () { + if (!selectionIsCurrent()) return; + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _trackSelectionFallbackTimer = null; + if (!_tracksReadyForSelection(player.state.tracks)) { + appLogger.w('Advertised native subtitle selection did not resolve before the 30-second deadline'); + } + unawaited(applyTrackSelection()); + }); + }); } bool _tracksReadyForSelection(Tracks tracks) { @@ -231,14 +260,29 @@ class TrackManager { if (!hasAnyTracks) return false; final info = mediaInfo; - if (info == null || tracks.subtitle.isNotEmpty) return true; + if (info == null || info.subtitleTracks.isEmpty) return true; + if (tracks.subtitle.isEmpty) return false; - // Plex can legitimately report subtitles without selecting one. During an - // in-place item reload Android clears the old track list before the new - // demuxed subtitles arrive; applying selection at the first audio-only - // update would treat that temporary empty subtitle list as an explicit - // server "off" decision and leave the next episode without selectable subs. - return info.subtitleTracks.isEmpty; + final nativeSubtitleTracks = tracks.subtitle + .where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id) + .toList(growable: false); + final preferred = preferredSubtitleTrack; + final preferredHasSemanticIdentity = + preferred != null && + preferred.id != SubtitleTrack.off.id && + (preferred.id.startsWith('source:') || + preferred.uri != null || + preferred.title != null || + preferred.language != null); + if (preferredHasSemanticIdentity) { + final service = TrackSelectionService(metadata: metadata, plexMediaInfo: info); + return service.findBestSubtitleMatch(nativeSubtitleTracks, preferred) != null; + } + + final serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull; + if (serverSelectedTrack == null) return true; + return findMpvTrackForPlexSubtitle(serverSelectedTrack, nativeSubtitleTracks, allPlexTracks: info.subtitleTracks) != + null; } /// Core track selection: delegates to [TrackSelectionService]. Returns @@ -249,10 +293,10 @@ class TrackManager { if (!selectionIsActive()) return false; if (_isApplyingTrackSelection) { - // Calls from the active generation are already represented by the - // in-flight selection. A replacement generation, however, must wait for - // stale work to unwind rather than losing its only selection request. - if (_applyingSelectionGeneration == selectionGeneration) return false; + // A later track-list event can make a same-generation selection materially + // different (notably when subtitles arrive while the five-second audio/rate + // fallback is still applying). Queue one pass after the current mutation + // chain rather than dropping that event. final activeSelectionDone = _selectionIdleCompleter?.future; if (activeSelectionDone == null) return false; await activeSelectionDone; @@ -261,7 +305,6 @@ class TrackManager { } _isApplyingTrackSelection = true; - _applyingSelectionGeneration = selectionGeneration; final idleCompleter = Completer(); _selectionIdleCompleter = idleCompleter; try { @@ -294,7 +337,6 @@ class TrackManager { return false; } finally { _isApplyingTrackSelection = false; - _applyingSelectionGeneration = null; if (identical(_selectionIdleCompleter, idleCompleter)) { _selectionIdleCompleter = null; idleCompleter.complete(); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 9209d5ba..4086fef3 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -85,46 +85,62 @@ SubtitleTrack? findMpvTrackForPlexSubtitle( List? allPlexTracks, }) { if (mpvTracks.isEmpty) return null; + final sourceId = int.tryParse(plexTrack.id.toString()); + if (sourceId != null) { + final exactSourceTrack = mpvTracks.where((track) => track.id == 'source:$sourceId').firstOrNull; + if (exactSourceTrack != null) return exactSourceTrack; + } - // For external subtitles, match by URI containing the Plex key - if (plexTrack.isExternal && plexTrack.key != null) { + // Keyed subtitles have a stable identity. Do not let a sidecar that has not + // arrived yet fall through to fuzzy language/title scoring. + final plexKey = plexTrack.key; + if (plexKey != null && plexKey.isNotEmpty) { for (final mpvTrack in mpvTracks) { - if (mpvTrack.isExternal && mpvTrack.uri != null) { - // Check if the MPV URI contains the Plex key path - if (mpvTrack.uri!.contains(plexTrack.key!)) { - return mpvTrack; - } + if (mpvTrack.isExternal && mpvTrack.uri?.contains(plexKey) == true) { + return mpvTrack; } } + return null; } // For internal subtitles, use scoring based on properties SubtitleTrack? bestMatch; int bestScore = 0; + bool bestMatchUsesContainerOrdinal = false; - // Ordinal tiebreaker: precompute position of plexTrack among internal tracks - final internalMpvTracks = allPlexTracks != null ? mpvTracks.where((t) => !t.isExternal).toList() : null; - final plexOrdinal = allPlexTracks != null - ? allPlexTracks.where((t) => !t.isExternal).toList().indexOf(plexTrack) - : -1; + // Ordinal identity: container sidecars expose embedded subtitle tracks as + // external media, but retain the source container's subtitle ordering. + final containerPlexTracks = allPlexTracks + ?.where((track) => track.key == null || track.key!.isEmpty) + .toList(growable: false); + final internalMpvTracks = allPlexTracks == null + ? null + : mpvTracks.where((track) => !track.isExternal || track.isContainer).toList(growable: false); + final plexOrdinal = containerPlexTracks?.indexOf(plexTrack) ?? -1; for (final mpvTrack in mpvTracks) { - // Skip external tracks when matching internal Plex tracks - if (!plexTrack.isExternal && mpvTrack.isExternal) continue; + // A container sidecar's subtitle tracks map to internal Plex streams. + if (!plexTrack.isExternal && mpvTrack.isExternal && !mpvTrack.isContainer) continue; final ordinalMatches = internalMpvTracks != null && plexOrdinal >= 0 && internalMpvTracks.indexOf(mpvTrack) == plexOrdinal; + // A container track has no stable native ID. Its source-container ordinal + // is authoritative; a metadata-identical earlier track is not a match. + if (mpvTrack.isContainer && plexOrdinal >= 0 && !ordinalMatches) continue; + final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; bestMatch = mpvTrack; + bestMatchUsesContainerOrdinal = mpvTrack.isContainer && ordinalMatches; } } - // Require at least language match for a valid match - return bestScore >= 10 ? bestMatch : null; + // Prefer metadata matches. Container sidecars may expose no language/title/ + // codec at all, so their stable subtitle order is the last-resort identity. + return bestScore >= 10 || bestMatchUsesContainerOrdinal ? bestMatch : null; } /// Find the Plex subtitle track that matches an MPV subtitle track @@ -134,43 +150,61 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle( List? allMpvTracks, }) { if (plexTracks.isEmpty) return null; + if (mpvTrack.id.startsWith('source:')) { + final sourceId = int.tryParse(mpvTrack.id.substring('source:'.length)); + if (sourceId != null) { + final exactSourceTrack = plexTracks.where((track) => track.id == sourceId).firstOrNull; + if (exactSourceTrack != null) return exactSourceTrack; + } + } - // For external subtitles, match by URI containing the Plex key + // A standalone keyed subtitle maps back only by its stable Plex key. + // Container sidecars may continue to the source-container matcher below. if (mpvTrack.isExternal && mpvTrack.uri != null) { for (final plexTrack in plexTracks) { - if (plexTrack.isExternal && plexTrack.key != null) { - if (mpvTrack.uri!.contains(plexTrack.key!)) { - return plexTrack; - } + final plexKey = plexTrack.key; + if (plexKey != null && plexKey.isNotEmpty && mpvTrack.uri!.contains(plexKey)) { + return plexTrack; } } + if (!mpvTrack.isContainer) return null; } // For internal subtitles, use scoring based on properties MediaSubtitleTrack? bestMatch; int bestScore = 0; + bool bestMatchUsesContainerOrdinal = false; - // Ordinal tiebreaker: precompute position of mpvTrack among internal tracks - final internalPlexTracks = allMpvTracks != null ? plexTracks.where((t) => !t.isExternal).toList() : null; - final mpvOrdinal = allMpvTracks != null ? allMpvTracks.where((t) => !t.isExternal).toList().indexOf(mpvTrack) : -1; + // Ordinal identity: container-sidecar tracks map back to source-container + // streams even though the native player marks their source as external. + final mpvIsInternal = !mpvTrack.isExternal || mpvTrack.isContainer; + final containerPlexTracks = allMpvTracks == null + ? null + : plexTracks.where((track) => track.key == null || track.key!.isEmpty).toList(growable: false); + final mpvOrdinal = allMpvTracks == null + ? -1 + : allMpvTracks.where((track) => !track.isExternal || track.isContainer).toList().indexOf(mpvTrack); for (final plexTrack in plexTracks) { - // Skip external Plex tracks when matching internal MPV tracks - if (!mpvTrack.isExternal && plexTrack.isExternal) continue; + if (mpvIsInternal && plexTrack.isExternal) continue; final ordinalMatches = - internalPlexTracks != null && mpvOrdinal >= 0 && internalPlexTracks.indexOf(plexTrack) == mpvOrdinal; + containerPlexTracks != null && mpvOrdinal >= 0 && containerPlexTracks.indexOf(plexTrack) == mpvOrdinal; + + if (mpvTrack.isContainer && containerPlexTracks != null && !ordinalMatches) continue; final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; bestMatch = plexTrack; + bestMatchUsesContainerOrdinal = mpvTrack.isContainer && ordinalMatches; } } - // Require at least language match for a valid match - return bestScore >= 10 ? bestMatch : null; + // Prefer metadata matches, with container order as the symmetric fallback + // needed to persist a metadata-free native track back to its Plex stream. + return bestScore >= 10 || bestMatchUsesContainerOrdinal ? bestMatch : null; } /// Find the MPV audio track that matches a Plex audio track @@ -568,11 +602,19 @@ class TrackSelectionService { return SubtitleTrack.off; } + if (preferred.id.startsWith('source:')) { + final sourceId = int.tryParse(preferred.id.substring('source:'.length)); + final sourceTrack = sourceId == null + ? null + : plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull; + if (sourceTrack == null) return null; + return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks); + } + final preferredUri = preferred.uri; if (preferredUri != null) { - for (final track in availableTracks) { - if (track.uri == preferredUri) return track; - } + final uriMatches = availableTracks.where((track) => track.uri == preferredUri).toList(growable: false); + if (uriMatches.length == 1) return uriMatches.single; } return findBestTrackMatch( @@ -720,7 +762,11 @@ class TrackSelectionService { /// Priority 3: User profile subtitle mode /// Priority 4: Default track /// Priority 5: Off - TrackSelectionResult selectSubtitleTrack( + /// + /// Returns null while the source catalog advertises subtitles but the + /// native player has not exposed any of them yet. That transient state is + /// not equivalent to an explicit server decision to turn subtitles off. + TrackSelectionResult? selectSubtitleTrack( List availableTracks, SubtitleTrack? preferredSubtitleTrack, AudioTrack? selectedAudioTrack, @@ -735,15 +781,14 @@ class TrackSelectionService { return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation); } } + if (preferredSubtitleTrack.id.startsWith('source:')) return null; } // Priority 2: Trust the server's selected track. Plex computes this from // account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex. final info = plexMediaInfo; if (info != null) { - final serverSelectedTrack = availableTracks.isNotEmpty - ? info.subtitleTracks.where((track) => track.selected).firstOrNull - : null; + final serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull; if (serverSelectedTrack != null) { final matchedMpvTrack = findMpvTrackForPlexSubtitle( @@ -755,6 +800,7 @@ class TrackSelectionService { if (matchedMpvTrack != null) { return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } + if (metadata.backend == MediaBackend.plex) return null; } else if (metadata.backend == MediaBackend.jellyfin) { final defaultStreamIndex = info.defaultSubtitleStreamIndex; if (defaultStreamIndex == -1) { @@ -779,9 +825,11 @@ class TrackSelectionService { } } } else if (metadata.backend == MediaBackend.plex && info.subtitleTracks.isNotEmpty) { - // Server has subtitle tracks but none selected — trust that decision + if (availableTracks.isEmpty) return null; + // Native tracks exist and none maps to a server-selected stream. return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); } + if (availableTracks.isEmpty && info.subtitleTracks.isNotEmpty) return null; } // Priority 3: Apply server profile subtitle mode when the backend exposes @@ -856,22 +904,27 @@ class TrackSelectionService { } } - // Select and apply subtitle track + // Select and apply subtitle track. A null result means source metadata + // advertises subtitles that the native player has not exposed yet. final subtitleResult = selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack); - final selectedSubtitleTrack = subtitleResult.track; - final subtitleName = selectedSubtitleTrack.id == 'no' - ? 'OFF' - : (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}'); - appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]'); - if (!canMutatePlayer()) return false; - final subtitleMutation = player.selectSubtitleTrack(selectedSubtitleTrack); - onPlayerMutationDispatched?.call(subtitleMutation); - await subtitleMutation; - if (!canMutatePlayer()) return false; + if (subtitleResult != null) { + final selectedSubtitleTrack = subtitleResult.track; + final subtitleName = selectedSubtitleTrack.id == 'no' + ? 'OFF' + : (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}'); + appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]'); + if (!canMutatePlayer()) return false; + final subtitleMutation = player.selectSubtitleTrack(selectedSubtitleTrack); + onPlayerMutationDispatched?.call(subtitleMutation); + await subtitleMutation; + if (!canMutatePlayer()) return false; - // Save to Plex if this was user's navigation preference (Priority 1) - if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) { - onSubtitleTrackChanged(selectedSubtitleTrack); + // Save to Plex if this was user's navigation preference (Priority 1) + if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) { + onSubtitleTrackChanged(selectedSubtitleTrack); + } + } else { + appLogger.d('Subtitle selection pending: native tracks have not arrived'); } // Apply preferred secondary subtitle track if provided (mpv-only) diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 7b5822eb..5a151f74 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -244,7 +244,7 @@ void main() { ); }); - test('ExoPlayer forwards external subtitle metadata at open', () async { + test('ExoPlayer forwards complete container metadata and rejects empty sidecar URIs', () async { final calls = []; await withMockPlayerChannels( @@ -252,18 +252,14 @@ void main() { eventChannelName: 'com.plezy/exo_player/events', methodHandler: (call) { calls.add(call); - switch (call.method) { - case 'initialize': - return Future.value(true); - default: - return Future.value(null); - } + return call.method == 'initialize' ? Future.value(true) : Future.value(null); }, testBody: () async { final player = PlayerAndroid(); try { + const containerUri = 'https://example.test/movie.mkv'; await player.open( - Media('https://example.test/movie.mkv'), + Media('https://example.test/transcode.m3u8'), externalSubtitles: const [ SubtitleTrack( id: 'external-sub', @@ -275,20 +271,44 @@ void main() { isExternal: true, uri: 'https://example.test/sub.srt', ), + SubtitleTrack( + id: 'container:1', + title: 'English Dialogue', + language: 'eng', + codec: 'ass', + isDefault: true, + isExternal: true, + isContainer: true, + uri: containerUri, + ), + SubtitleTrack( + id: 'container:2', + title: 'English Signs', + language: 'eng', + codec: 'ass', + isForced: true, + isExternal: true, + isContainer: true, + uri: containerUri, + ), + SubtitleTrack(id: 'invalid', uri: '', isExternal: true, isContainer: true), ], ); final openCall = calls.singleWhere((call) => call.method == 'open'); final args = Map.from(openCall.arguments as Map); - final external = args['externalSubtitles'] as List; - final subtitle = Map.from(external.single as Map); + final external = (args['externalSubtitles'] as List) + .map((entry) => Map.from(entry as Map)) + .toList(); - expect(subtitle['uri'], 'https://example.test/sub.srt'); - expect(subtitle['title'], 'English Forced'); - expect(subtitle['language'], 'eng'); - expect(subtitle['codec'], 'srt'); - expect(subtitle['isDefault'], isTrue); - expect(subtitle['isForced'], isTrue); + expect(external, hasLength(3)); + expect(external.first['uri'], 'https://example.test/sub.srt'); + expect(external.first['title'], 'English Forced'); + expect(external.first['isDefault'], isTrue); + expect(external.first['isForced'], isTrue); + expect(external.skip(1).map((entry) => entry['uri']).toSet(), {containerUri}); + expect(external.skip(1).map((entry) => entry['title']), ['English Dialogue', 'English Signs']); + expect(external.skip(1).every((entry) => entry['isContainer'] == true), isTrue); } finally { await player.dispose(); } @@ -612,41 +632,119 @@ void main() { ); }); - test('MPV preserves external subtitle metadata for loadfile sidecars', () async { + test('MPV restores per-stream metadata while loading a shared container once', () async { + final calls = []; await withMockPlayerChannels( methodChannelName: 'com.plezy/mpv_player', eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) { + calls.add(call); + return call.method == 'initialize' ? Future.value(true) : Future.value(null); + }, testBody: () async { final player = PlayerNative(); try { - const subtitleUri = 'https://example.test/subtitles/en-forced.srt'; + const subtitleUri = 'https://example.test/movie.mkv?X-Plex-Token=secret'; await player.open( - Media('https://example.test/movie.mkv'), + Media('https://example.test/transcode.m3u8'), externalSubtitles: const [ SubtitleTrack( - id: 'server-subtitle', + id: 'container:1', uri: subtitleUri, - title: 'English Forced', + title: 'English Dialogue', language: 'eng', - codec: 'srt', + codec: 'ass', isDefault: true, + isExternal: true, + isContainer: true, + ), + SubtitleTrack( + id: 'container:2', + uri: subtitleUri, + title: 'English Signs', + language: 'eng', + codec: 'ass', isForced: true, isExternal: true, + isContainer: true, ), ], ); - - player.handlePropertyChange('track-list', const [ - {'type': 'sub', 'id': '1', 'codec': 'subrip', 'external': true, 'external-filename': subtitleUri}, + expect(_loadfileArgs(calls), [ + 'loadfile', + 'https://example.test/transcode.m3u8', + 'replace', + '-1', + 'sub-files=${_fixedLengthPathList([subtitleUri])}', ]); - final subtitle = player.state.tracks.subtitle.single; - expect(subtitle.title, 'English Forced'); - expect(subtitle.language, 'eng'); - expect(subtitle.codec, 'srt'); - expect(subtitle.isDefault, isTrue); - expect(subtitle.isForced, isTrue); - expect(subtitle.uri, subtitleUri); + player.handlePropertyChange('track-list', const [ + {'type': 'audio', 'id': 'sidecar-audio', 'external': true, 'external-filename': subtitleUri}, + { + 'type': 'sub', + 'id': '1', + 'title': 'movie.mkv?X-Plex-Token=secret', + 'default': false, + 'forced': false, + 'external': true, + 'external-filename': subtitleUri, + }, + { + 'type': 'sub', + 'id': '2', + 'title': 'movie.mkv?X-Plex-Token=secret', + 'default': false, + 'forced': false, + 'external': true, + 'external-filename': subtitleUri, + }, + ]); + + expect(player.state.tracks.audio, isEmpty); + final subtitles = player.state.tracks.subtitle; + expect(subtitles, hasLength(2)); + expect(subtitles.map((track) => track.title), ['English Dialogue', 'English Signs']); + expect(subtitles.map((track) => track.language), ['eng', 'eng']); + expect(subtitles.map((track) => track.codec), ['ass', 'ass']); + expect(subtitles.map((track) => track.isDefault), [true, false]); + expect(subtitles.map((track) => track.isForced), [false, true]); + expect(subtitles.every((track) => track.uri == subtitleUri && track.isContainer), isTrue); + } finally { + await player.dispose(); + } + }, + ); + }); + + test('MPV keeps native metadata fallbacks for non-container subtitles', () async { + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) => call.method == 'initialize' ? Future.value(true) : Future.value(null), + testBody: () async { + final player = PlayerNative(); + try { + const sidecarUri = 'https://example.test/subtitle.srt'; + await player.open( + Media('https://example.test/movie.mkv'), + externalSubtitles: const [SubtitleTrack(id: 'external', uri: sidecarUri, isExternal: true)], + ); + + player.handlePropertyChange('track-list', const [ + { + 'type': 'sub', + 'id': 'external', + 'title': 'English Dialogue - SRT', + 'lang': 'eng', + 'codec': 'subrip', + 'external': true, + 'external-filename': sidecarUri, + }, + {'type': 'sub', 'id': 'embedded', 'title': 'French Dialogue - ASS', 'lang': 'fre', 'codec': 'ass'}, + ]); + + expect(player.state.tracks.subtitle.map((track) => track.title), ['English Dialogue', 'French Dialogue']); + expect(player.state.tracks.subtitle.map((track) => track.language), ['eng', 'fre']); } finally { await player.dispose(); } diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index d182810e..f98eb4e6 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart'; MediaSubtitleTrack _sourceSubtitle( int id, { String language = 'eng', + String codec = 'srt', bool forced = false, bool selected = false, bool external = false, @@ -20,6 +21,7 @@ MediaSubtitleTrack _sourceSubtitle( id: id, language: language, languageCode: language, + codec: codec, title: 'Subtitle $id', selected: selected, forced: forced, @@ -28,16 +30,30 @@ MediaSubtitleTrack _sourceSubtitle( ); } -PlaybackSubtitleSidecar _sidecar(int id, {String language = 'eng', bool isDefault = false}) { +PlaybackSubtitleSidecar _sidecar( + int id, { + String language = 'eng', + bool isDefault = false, + bool preload = false, + bool isContainer = false, + String? uri, +}) { + final sidecarUri = uri ?? 'https://example.test/subtitles/$id.srt'; return PlaybackSubtitleSidecar( sourceStreamId: id, - track: SubtitleTrack.uri( - 'https://example.test/subtitles/$id.srt', - title: 'Subtitle $id', - language: language, - codec: 'srt', - isDefault: isDefault, - ), + preload: preload, + track: isContainer + ? SubtitleTrack( + id: 'container:$id', + title: 'Subtitle $id', + language: language, + codec: 'srt', + isDefault: isDefault, + isExternal: true, + isContainer: true, + uri: sidecarUri, + ) + : SubtitleTrack.uri(sidecarUri, title: 'Subtitle $id', language: language, codec: 'srt', isDefault: isDefault), ); } @@ -46,17 +62,18 @@ MediaSourceInfo _mediaInfo(List subtitles) { } void main() { - group('direct-play source routing', () { + group('source subtitle routing', () { test('matches an embedded source to its loaded native track', () { final source = _sourceSubtitle(2, language: 'eng'); const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt'); expect( - PlaybackSubtitleResolver.nativeTrackForDirectPlaySource( + PlaybackSubtitleResolver.nativeTrackForSource( sourceTrack: source, nativeTracks: const [native], allSourceTracks: [source], isResolvedSidecar: false, + isContainerSidecar: false, ), native, ); @@ -81,11 +98,12 @@ void main() { ); expect( - PlaybackSubtitleResolver.nativeTrackForDirectPlaySource( + PlaybackSubtitleResolver.nativeTrackForSource( sourceTrack: source, nativeTracks: const [other], allSourceTracks: [source], isResolvedSidecar: true, + isContainerSidecar: false, ), isNull, ); @@ -117,15 +135,50 @@ void main() { const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt'); expect( - PlaybackSubtitleResolver.nativeTrackForDirectPlaySource( + PlaybackSubtitleResolver.nativeTrackForSource( sourceTrack: source, nativeTracks: const [native], allSourceTracks: [source], isResolvedSidecar: false, + isContainerSidecar: false, ), native, ); }); + test('matches a requested source among tracks from one container sidecar', () { + final sources = [_sourceSubtitle(2, language: 'eng'), _sourceSubtitle(3, language: 'eng')]; + const nativeTracks = [ + SubtitleTrack( + id: '7', + title: 'Subtitle 2', + language: 'eng', + codec: 'srt', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ), + SubtitleTrack( + id: '8', + title: 'Subtitle 3', + language: 'eng', + codec: 'srt', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ), + ]; + + expect( + PlaybackSubtitleResolver.nativeTrackForSource( + sourceTrack: sources.last, + nativeTracks: nativeTracks, + allSourceTracks: sources, + isResolvedSidecar: true, + isContainerSidecar: true, + ), + nativeTracks.last, + ); + }); }); final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie); @@ -160,6 +213,24 @@ void main() { expect(result.sidecarsAtOpen, isEmpty); }); + test('retains each source metadata row for a shared preloaded container', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3)]), + sidecars: [ + _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, + ); + + expect(result.isOff, isTrue); + expect(result.sidecarsAtOpen, hasLength(2)); + expect(result.sidecarsAtOpen.every((track) => track.isContainer), isTrue); + expect(result.sidecarsAtOpen.map((track) => track.id), ['container:2', 'container:3']); + expect(result.sidecarsAtOpen.map((track) => track.uri).toSet(), {'https://example.test/video.mkv'}); + }); + test('explicit source selection wins over the server default', () { final mediaInfo = _mediaInfo([ _sourceSubtitle(2, selected: true, usesExternalDelivery: true), @@ -179,6 +250,82 @@ void main() { expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt'); }); + test('explicit source identity wins when subtitle metadata is identical', () { + final mediaInfo = _mediaInfo([ + MediaSubtitleTrack( + id: 2, + language: 'eng', + languageCode: 'eng', + codec: 'ass', + title: 'English', + selected: true, + forced: false, + ), + MediaSubtitleTrack( + id: 3, + language: 'eng', + languageCode: 'eng', + codec: 'ass', + title: 'English', + selected: false, + forced: false, + ), + ]); + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: mediaInfo, + sidecars: [ + _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), + ); + + expect(result.primarySourceStreamId, 3); + }); + + test('source identity is ignored across media-source changes', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([ + _sourceSubtitle(3, language: 'eng', selected: true, usesExternalDelivery: true), + _sourceSubtitle(7, language: 'fra', usesExternalDelivery: true), + ]), + sidecars: [ + _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', + ), + preserveSourceIdentity: false, + ); + + expect(result.primarySourceStreamId, 7); + }); + + test('unmatched source identity cannot bind a reused id after a source change', () { + final result = PlaybackSubtitleResolver.resolve( + 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', + ), + preserveSourceIdentity: false, + ); + + expect(result.primarySourceStreamId, 7); + }); + test('item-change semantic preference selects the matching new sidecar', () { final result = PlaybackSubtitleResolver.resolve( metadata: metadata, @@ -240,6 +387,17 @@ void main() { expect(result.sidecarsAtOpen, isEmpty); }); + test('selected metadata-free embedded subtitle resolves by source identity', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([MediaSubtitleTrack(id: 2, codec: 'ass', selected: true, forced: false)]), + sidecars: const [], + ); + + expect(result.isOff, isFalse); + expect(result.primarySourceStreamId, 2); + }); + test('preferred secondary subtitle attaches a second distinct sidecar', () { final mediaInfo = _mediaInfo([ _sourceSubtitle(2, selected: true, usesExternalDelivery: true), diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 05dcf25b..b588af83 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -10,7 +10,6 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_source_info.dart'; -import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -44,8 +43,11 @@ void main() { ); } - List buildTranscodeSubtitles(PlexClient client, List subtitleTracks) { - return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks)); + List buildTranscodeSubtitles(PlexClient client, List subtitleTracks) { + return client.buildTranscodeSidecarSubtitlesForTesting( + mediaInfoWithSubtitles(subtitleTracks), + 'https://plex.example.com/video.mkv?X-Plex-Token=token', + ); } test('selectStreams sends audio stream selection with allParts', () async { @@ -129,6 +131,97 @@ void main() { expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue); }); + test('transcode initialization wires a subtitle-free HLS request to the complete sidecar catalog', () async { + final requests = []; + final client = makeClient((request) async { + requests.add(request); + if (request.url.path == '/library/metadata/42') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'title': 'Movie', + 'Media': [ + { + 'id': 7, + 'container': 'mkv', + 'Part': [ + { + 'id': 99, + 'key': '/library/parts/99/file.mkv', + 'Stream': [ + {'streamType': 1, 'id': 300, 'codec': 'h264'}, + {'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true}, + { + 'streamType': 3, + 'id': 401, + 'index': 1, + 'codec': 'ass', + 'languageCode': 'eng', + 'selected': true, + }, + { + 'streamType': 3, + 'id': 402, + 'index': 2, + 'codec': 'srt', + 'languageCode': 'swe', + 'key': '/library/streams/402', + 'external': true, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/video/:/transcode/universal/decision') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('unexpected request', 500); + }); + addTearDown(client.close); + + final result = await client.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ), + ); + + final decisionRequest = requests.singleWhere( + (request) => request.url.path == '/video/:/transcode/universal/decision', + ); + expect(decisionRequest.url.queryParameters['subtitles'], 'none'); + expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse); + expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse); + expect(result.isTranscoding, isTrue); + expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?')); + expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]); + expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue); + expect(result.subtitleSidecars.first.track.isContainer, isTrue); + expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt')); + }); + test('playback uses metadata availability flags without probing part URLs', () async { final requests = []; final client = makeClient((request) async { @@ -331,16 +424,17 @@ void main() { expect(result.selectedMediaIndex, 1); }); - test('transcode subtitle sidecars only use real Plex stream keys', () { + test('transcode subtitle catalog includes embedded and keyed Plex streams', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); final subtitles = buildTranscodeSubtitles(client, [ - MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false), + MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false), MediaSubtitleTrack( id: 402, codec: 'srt', - languageCode: 'eng', + languageCode: 'swe', + title: 'External', selected: false, forced: false, key: '/library/streams/402', @@ -348,63 +442,50 @@ void main() { ), ]); - expect(subtitles, hasLength(1)); - expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token'); + expect(subtitles, hasLength(2)); + expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]); + expect(subtitles.every((sidecar) => sidecar.preload), isTrue); + expect(subtitles.first.track.isContainer, isTrue); + expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token'); + expect(subtitles.last.track.isContainer, isFalse); + expect( + subtitles.last.track.uri, + 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token', + ); }); - test('transcode negotiation honors an explicit source subtitle preference', () { - final client = makeClient((_) async => http.Response('not used', 500)); + test('tokenless transcode keeps embedded and keyed subtitle sources', () { + final client = testPlexClient( + serverId: ServerId('server-id'), + token: null, + handler: (_) async => http.Response('not used', 500), + ); addTearDown(client.close); - final info = mediaInfoWithSubtitles([ - MediaSubtitleTrack(id: 401, languageCode: 'eng', selected: true, forced: false), - MediaSubtitleTrack(id: 402, languageCode: 'swe', selected: false, forced: false), - ]); - final selected = client.resolveTranscodeSubtitleTrackForTesting( - info, - const SubtitleTrack(id: 'source:402', language: 'swe'), + final subtitles = client.buildTranscodeSidecarSubtitlesForTesting( + mediaInfoWithSubtitles([ + MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', selected: true, forced: false), + MediaSubtitleTrack( + id: 402, + codec: 'srt', + languageCode: 'swe', + selected: false, + forced: false, + key: '/library/streams/402', + external: true, + ), + ]), + 'https://plex.example.com/video.mkv', ); - expect(selected?.id, 402); - expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull); + expect(subtitles, hasLength(2)); + expect(subtitles.first.track.isContainer, isTrue); + expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv'); + expect(subtitles.last.track.isContainer, isFalse); + expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8'); }); - test('transcode negotiation falls back to the new source default for a stale source id', () { - final client = makeClient((_) async => http.Response('not used', 500)); - addTearDown(client.close); - final info = mediaInfoWithSubtitles([ - MediaSubtitleTrack(id: 501, languageCode: 'eng', selected: true, forced: false), - MediaSubtitleTrack(id: 502, languageCode: 'swe', selected: false, forced: false), - ]); - - final selected = client.resolveTranscodeSubtitleTrackForTesting( - info, - const SubtitleTrack(id: 'source:402', language: 'und'), - ); - - expect(selected?.id, 501); - }); - - test('selected internal text subtitles are not attached as external sidecars', () { - final client = makeClient((_) async => http.Response('not used', 500)); - addTearDown(client.close); - - final subtitles = buildTranscodeSubtitles(client, [ - MediaSubtitleTrack( - id: 401, - codec: 'ass', - language: 'English', - languageCode: 'eng', - title: 'Signs/Songs', - selected: true, - forced: false, - ), - ]); - - expect(subtitles, isEmpty); - }); - - test('selected internal text subtitles are segmented into the HLS transcode', () { + test('video transcode stays subtitle-free while preserving the HLS profile', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); @@ -414,19 +495,12 @@ void main() { preset: TranscodeQualityPreset.p720_3mbps, sessionIdentifier: 'session-id', transcodeSessionId: 'transcode-id', - selectedSubtitleTrack: MediaSubtitleTrack( - id: 401, - codec: 'ass', - languageCode: 'eng', - selected: true, - forced: false, - ), ); expect(params['protocol'], 'hls'); - expect(params['subtitles'], 'segmented'); - expect(params['subtitleStreamID'], '401'); - expect(params['advancedSubtitles'], 'text'); + expect(params['subtitles'], 'none'); + expect(params.containsKey('subtitleStreamID'), isFalse); + expect(params.containsKey('advancedSubtitles'), isFalse); expect(params.containsKey('X-Plex-Chunked'), isFalse); expect(params['X-Plex-Incomplete-Segments'], '1'); expect(params['X-Plex-Client-Profile-Name'], 'Generic'); @@ -444,8 +518,7 @@ void main() { profile, contains( 'add-transcode-target(type=videoProfile&context=streaming' - '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video' - '&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)', + '&protocol=hls&container=mpegts', ), ); expect( @@ -495,43 +568,20 @@ void main() { expect(params['partIndex'], '2'); }); - test('selected image subtitles are burned into HLS without advancedSubtitles', () { + test('image-based embedded subtitles use the shared container sidecar', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); - final params = client.buildTranscodeParamsForTesting( - ratingKey: '42', - mediaIndex: 0, - preset: TranscodeQualityPreset.p720_3mbps, - sessionIdentifier: 'session-id', - transcodeSessionId: 'transcode-id', - selectedSubtitleTrack: MediaSubtitleTrack( - id: 401, - codec: 'pgs', - languageCode: 'eng', - selected: true, - forced: false, - ), - ); - - expect(params['subtitles'], 'burn'); - expect(params['subtitleStreamID'], '401'); - expect(params['protocol'], 'hls'); - expect(params.containsKey('advancedSubtitles'), isFalse); - }); - - test('image-based embedded subtitles are rendered by HLS, not attached as sidecars', () { - final client = makeClient((_) async => http.Response('not used', 500)); - addTearDown(client.close); - - // Embedded bitmap streams have no Plex `key`; the HLS request burns the - // selected track into the video rendition. final subtitles = buildTranscodeSubtitles(client, [ MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), - MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false), + MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false), ]); - expect(subtitles, isEmpty); + expect(subtitles, hasLength(2)); + expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue); + expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), { + 'https://plex.example.com/video.mkv?X-Plex-Token=token', + }); }); group('playback metadata failure contract', () { diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index a4fdce46..4266e06c 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -7,6 +8,8 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; 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/track_manager.dart'; @@ -369,6 +372,361 @@ void main() { expect(player.selectedSubtitle, hasLength(1)); expect(player.selectedSubtitle.single.id, 'no'); }); + + test('waits through a partial catalog until the selected Plex subtitle arrives', () async { + await SettingsService.getInstance(); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true)); + addTearDown(mgr.dispose); + + mgr.applyTrackSelectionWhenReady(); + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '11', language: 'fre')], + ), + ); + await _drainAsync(); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [ + SubtitleTrack(id: '11', language: 'fre'), + SubtitleTrack(id: '10', language: 'eng'), + ], + ), + ); + await _drainAsync(); + + expect(player.selectedSubtitle.map((track) => track.id), ['10']); + }); + + test('waits for a preferred subtitle even when the server-selected track arrives first', () async { + await SettingsService.getInstance(); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make( + player: player, + mediaInfo: _mediaInfoWithSubtitles(selected: true), + preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'), + ); + addTearDown(mgr.dispose); + + mgr.applyTrackSelectionWhenReady(); + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '10', language: 'eng')], + ), + ); + await _drainAsync(); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [ + SubtitleTrack(id: '10', language: 'eng'), + SubtitleTrack(id: '11', language: 'fre'), + ], + ), + ); + await _drainAsync(); + + expect(player.selectedSubtitle.map((track) => track.id), ['11']); + }); + + test('source identity waits for the intended identical container track', () async { + await SettingsService.getInstance(); + final mediaInfo = MediaSourceInfo( + videoUrl: 'https://example.com/transcode.m3u8', + partId: 99, + audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)], + subtitleTracks: [ + MediaSubtitleTrack( + id: 30, + index: 0, + languageCode: 'eng', + title: 'English', + codec: 'ass', + selected: false, + forced: false, + ), + MediaSubtitleTrack( + id: 31, + index: 1, + languageCode: 'eng', + title: 'English', + codec: 'ass', + selected: true, + forced: false, + ), + ], + chapters: const [], + ); + const firstNativeTrack = SubtitleTrack( + id: 'native-0', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.com/video.mkv', + ); + const secondNativeTrack = SubtitleTrack( + id: 'native-1', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.com/video.mkv', + ); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: 'audio', language: 'eng')], + subtitle: [firstNativeTrack], + ), + ); + final mgr = _make( + player: player, + mediaInfo: mediaInfo, + preferredSubtitleTrack: const SubtitleTrack( + id: 'source:31', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.com/video.mkv', + ), + ); + addTearDown(mgr.dispose); + + mgr.applyTrackSelectionWhenReady(); + await _drainAsync(); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: 'audio', language: 'eng')], + subtitle: [firstNativeTrack, secondNativeTrack], + ), + ); + await _drainAsync(); + + expect(player.selectedSubtitle.map((track) => track.id), ['native-1']); + }); + + test('deferred transcode source choice applies after native discovery without requesting a reload', () async { + await SettingsService.getInstance(); + final sourceTrack = MediaSubtitleTrack( + id: 31, + index: 0, + languageCode: 'eng', + title: 'English', + codec: 'ass', + selected: false, + forced: false, + ); + final mediaInfo = MediaSourceInfo( + videoUrl: 'https://example.com/transcode.m3u8', + partId: 99, + audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)], + subtitleTracks: [sourceTrack], + chapters: const [], + ); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: 'audio', language: 'eng')], + ), + ); + final mgr = _make(player: player, mediaInfo: mediaInfo); + addTearDown(mgr.dispose); + SubtitleTrack? persistedTrack; + int? persistedSourceStreamId; + + final handledLocally = await deferTranscodeSubtitleSelection( + trackManager: mgr, + sourceTrack: sourceTrack, + sourceSidecar: const PlaybackSubtitleSidecar( + sourceStreamId: 31, + preload: true, + track: SubtitleTrack( + id: 'container:31', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.com/video.mkv', + ), + ), + sourceStreamId: 31, + onSubtitleTrackChanged: (track, {sourceStreamId}) async { + persistedTrack = track; + persistedSourceStreamId = sourceStreamId; + }, + shouldContinue: () => true, + ); + + expect(handledLocally, isTrue); + expect(mgr.preferredSubtitleTrack?.id, 'source:31'); + expect(persistedTrack?.id, 'source:31'); + expect(persistedSourceStreamId, 31); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: 'audio', language: 'eng')], + subtitle: [ + SubtitleTrack( + id: 'native-0', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.com/video.mkv', + ), + ], + ), + ); + await _drainAsync(); + + expect(player.selectedSubtitle.map((track) => track.id), ['native-0']); + }); + + test('five-second fallback keeps listening and applies a late advertised subtitle', () async { + await SettingsService.getInstance(); + + fakeAsync((async) { + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true)); + + mgr.applyTrackSelectionWhenReady(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + expect(player.selectedAudio, hasLength(1)); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '10', language: 'eng')], + ), + ); + async.flushMicrotasks(); + + expect(player.selectedSubtitle.map((track) => track.id), ['10']); + expect(async.nonPeriodicTimerCount, 0); + mgr.dispose(); + }); + }); + + test('five-second fallback keeps listening through a partial subtitle catalog', () async { + await SettingsService.getInstance(); + + fakeAsync((async) { + final mediaInfo = MediaSourceInfo( + videoUrl: 'https://example.com/transcode.m3u8', + audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)], + subtitleTracks: [ + MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: false, forced: false), + MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: true, forced: false), + ], + chapters: const [], + ); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '10', language: 'eng')], + ), + ); + final mgr = _make( + player: player, + mediaInfo: mediaInfo, + preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'), + ); + + mgr.applyTrackSelectionWhenReady(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + expect(player.selectedAudio, hasLength(1)); + expect(player.selectedSubtitle, isEmpty); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [ + SubtitleTrack(id: '10', language: 'eng'), + SubtitleTrack(id: '11', language: 'fre'), + ], + ), + ); + async.flushMicrotasks(); + + expect(player.selectedSubtitle.map((track) => track.id), ['11']); + expect(async.nonPeriodicTimerCount, 0); + mgr.dispose(); + }); + }); + + test('late subtitle arrival queues behind an in-flight fallback selection', () async { + await SettingsService.getInstance(); + + fakeAsync((async) { + final audioSelection = Completer(); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + var blockFirstAudioSelection = true; + player.onSelectAudioTrack = (_) { + if (!blockFirstAudioSelection) return Future.value(); + blockFirstAudioSelection = false; + return audioSelection.future; + }; + final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true)); + + mgr.applyTrackSelectionWhenReady(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + expect(player.selectedAudio, hasLength(1)); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '10', language: 'eng')], + ), + ); + async.flushMicrotasks(); + expect(player.selectedSubtitle, isEmpty); + + audioSelection.complete(); + async.flushMicrotasks(); + + expect(player.selectedSubtitle.map((track) => track.id), ['10']); + expect(async.nonPeriodicTimerCount, 0); + mgr.dispose(); + }); + }); }); group('applyTrackSelection ownership', () { diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index 2206ecca..981df165 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -99,7 +99,18 @@ SubtitleTrack _sub( String? codec, bool isDefault = false, bool isForced = false, -}) => SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault, isForced: isForced); + bool isExternal = false, + bool isContainer = false, +}) => SubtitleTrack( + id: id, + language: lang, + title: title, + codec: codec, + isDefault: isDefault, + isForced: isForced, + isExternal: isExternal, + isContainer: isContainer, +); MediaAudioTrack _plexAudio( int id, { @@ -132,6 +143,8 @@ MediaSubtitleTrack _plexSub( bool selected = false, bool forced = false, String? codec, + bool external = false, + String? key, }) { return MediaSubtitleTrack( id: id, @@ -142,6 +155,8 @@ MediaSubtitleTrack _plexSub( selected: selected, forced: forced, codec: codec, + external: external, + key: key, ); } @@ -438,14 +453,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 SubtitleTrack(id: 'no'), 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, _sub('99', lang: 'fre'), null)!; expect(result.priority, TrackSelectionPriority.navigation); expect(result.track.id, '2'); }); @@ -458,11 +473,90 @@ void main() { _plexSub(11, language: 'fre', languageCode: 'fre', selected: true), ], ); - final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); + final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.language, 'fre'); }); + test('partial native catalog stays undetermined until the selected Plex track arrives', () { + final info = _info( + subs: [ + _plexSub(10, language: 'eng', selected: true), + _plexSub(11, language: 'fre'), + ], + ); + + final result = _svc(info: info).selectSubtitleTrack([_sub('2', lang: 'fre')], null, null); + + expect(result, isNull); + }); + + test('preferred source waits for its ordinal in a partial identical container catalog', () { + final info = _info( + subs: [ + _plexSub(30, index: 0, language: 'eng', title: 'English', codec: 'ass'), + _plexSub(31, index: 1, language: 'eng', title: 'English', codec: 'ass', selected: true), + ], + ); + const preferred = SubtitleTrack( + id: 'source:31', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ); + const first = SubtitleTrack( + id: 'native-0', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ); + const second = SubtitleTrack( + id: 'native-1', + language: 'eng', + title: 'English', + codec: 'ass', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ); + 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'); + }); + + test('preferred keyed source does not fuzzy-match an early same-language container track', () { + final info = _info( + subs: [ + _plexSub(10, language: 'eng', codec: 'srt', external: true, key: '/library/streams/10'), + _plexSub(11, language: 'eng', codec: 'srt'), + ], + ); + const preferred = SubtitleTrack( + id: 'source:10', + language: 'eng', + codec: 'srt', + isExternal: true, + uri: 'https://example.test/library/streams/10.srt', + ); + const earlyContainer = SubtitleTrack( + id: 'native-0', + language: 'eng', + codec: 'srt', + isExternal: true, + isContainer: true, + uri: 'https://example.test/video.mkv', + ); + + expect(_svc(info: info).selectSubtitleTrack(const [earlyContainer], preferred, null), isNull); + }); + test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () { final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; final info = _info( @@ -475,7 +569,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), info: info, - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.language, 'fre'); }); @@ -489,7 +583,7 @@ void main() { _plexSub(11, language: 'fre'), ], ); - final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); + final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.id, 'no'); }); @@ -505,7 +599,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), info: info, - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.defaultTrack); expect(result.track.id, '2'); }); @@ -522,7 +616,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), info: info, - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.language, 'fre'); }); @@ -539,7 +633,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), info: info, - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.id, 'no'); }); @@ -549,7 +643,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none), - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, 'no'); }); @@ -563,7 +657,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced), - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, '2'); }); @@ -573,7 +667,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced), - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, 'no'); }); @@ -583,7 +677,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always), - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, '2'); }); @@ -593,7 +687,7 @@ void main() { final result = _svc( metadata: _meta(backend: MediaBackend.jellyfin), profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always), - ).selectSubtitleTrack(tracks, null, null); + ).selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, '2'); }); @@ -607,7 +701,7 @@ void main() { defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.smart, ), - ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng')); + ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'))!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, '2'); }); @@ -621,30 +715,57 @@ void main() { defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.smart, ), - ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn')); + ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'))!; expect(result.priority, TrackSelectionPriority.profile); expect(result.track.id, '2'); }); test('Priority 3: default-flagged track when no Plex info', () { final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)]; - final result = _svc().selectSubtitleTrack(tracks, null, null); + final result = _svc().selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.defaultTrack); expect(result.track.id, '2'); }); test('Priority 4: off when no default and no info', () { final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; - final result = _svc().selectSubtitleTrack(tracks, null, null); + final result = _svc().selectSubtitleTrack(tracks, null, null)!; expect(result.priority, TrackSelectionPriority.off); expect(result.track.id, 'no'); }); test('Priority 4: off when no available tracks at all', () { - final result = _svc().selectSubtitleTrack(const [], null, null); + final result = _svc().selectSubtitleTrack(const [], null, null)!; expect(result.priority, TrackSelectionPriority.off); expect(result.track.id, 'no'); }); + + test('empty native track list remains undetermined when Plex advertises subtitles', () { + final info = _info(subs: [_plexSub(10, language: 'eng', selected: true)]); + + final result = _svc(info: info).selectSubtitleTrack(const [], null, null); + + expect(result, isNull); + }); + + test('empty native track list is also undetermined when Plex selected no subtitle', () { + final info = _info(subs: [_plexSub(10, language: 'eng')]); + + final result = _svc(info: info).selectSubtitleTrack(const [], null, null); + + expect(result, isNull); + }); + + test('selected keyed sidecar remains pending when only a same-language container has arrived', () { + final info = _info( + subs: [_plexSub(10, language: 'eng', selected: true, external: true, key: '/library/streams/10')], + ); + final earlyContainer = _sub('20', lang: 'eng', isExternal: true, isContainer: true); + + final result = _svc(info: info).selectSubtitleTrack([earlyContainer], null, null); + + expect(result, isNull); + }); }); // ============================================================ @@ -691,6 +812,22 @@ void main() { }); }); + group('container-sidecar ordinal fallback', () { + final plexTracks = [_plexSub(40, index: 0), _plexSub(41, index: 1)]; + final nativeTracks = [ + _sub('2_0', isExternal: true, isContainer: true), + _sub('2_1', isExternal: true, isContainer: true), + ]; + + test('maps a metadata-free Plex stream to its container track', () { + expect(findMpvTrackForPlexSubtitle(plexTracks[1], nativeTracks, allPlexTracks: plexTracks), nativeTracks[1]); + }); + + test('maps a metadata-free container track back to its Plex stream', () { + expect(findPlexTrackForMpvSubtitle(nativeTracks[0], plexTracks, allMpvTracks: nativeTracks)?.id, 40); + }); + }); + group('findPlexTrackForMpvAudio - same-language disambiguation', () { // Two French audio tracks differing only by channel count, titles null. final plexTracks = [