@@ -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<MediaItem.SubtitleConfiguration>()
|
||||
private val externalSubtitleUris = mutableListOf<String>()
|
||||
private val externalSubtitleContainerUris = mutableListOf<String>()
|
||||
private var playbackMediaSourceFactory: DefaultMediaSourceFactory? = null
|
||||
private var currentMediaUri: String? = null
|
||||
private var currentHeaders: Map<String, String>? = 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<MediaSource>(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
|
||||
|
||||
|
||||
@@ -1258,6 +1258,7 @@ class ExoPlayerPlugin :
|
||||
val escapedUris = externalSubtitles.orEmpty()
|
||||
.mapNotNull { it["uri"] as? String }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.map(::escapeMpvPathListEntry)
|
||||
.toList()
|
||||
|
||||
|
||||
@@ -585,6 +585,31 @@ class ExoPlayerPluginTest {
|
||||
core.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mpvFallbackLoadsSharedSubtitleContainerOnce() {
|
||||
val plugin = ExoPlayerPlugin()
|
||||
val options = mutableListOf<String>()
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+21
-18
@@ -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<SubtitleTrack> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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?,
|
||||
));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -72,7 +72,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
Duration? _timelineDuration;
|
||||
int _nextPropId = 0;
|
||||
final Map<int, String> _propIdToName = {};
|
||||
Map<String, SubtitleTrack> _externalSubtitleMetadataByUri = const {};
|
||||
Map<String, List<SubtitleTrack>> _externalSubtitleMetadataByUri = const {};
|
||||
bool _primaryMediaLoadStarted = false;
|
||||
bool _primaryMediaReadyEmitted = false;
|
||||
|
||||
@@ -499,6 +499,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
final subtitleTracks = <SubtitleTrack>[];
|
||||
String? selectedAudioId;
|
||||
String? selectedSubtitleId;
|
||||
final containerMetadataIndexes = <String, int>{};
|
||||
|
||||
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<SubtitleTrack>? externalSubtitles) {
|
||||
final metadataByUri = <String, SubtitleTrack>{};
|
||||
final metadataByUri = <String, List<SubtitleTrack>>{};
|
||||
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
|
||||
final uri = subtitle.uri;
|
||||
if (uri != null && uri.isNotEmpty) {
|
||||
metadataByUri[uri] = subtitle;
|
||||
(metadataByUri[uri] ??= <SubtitleTrack>[]).add(subtitle);
|
||||
}
|
||||
}
|
||||
_externalSubtitleMetadataByUri = metadataByUri;
|
||||
}
|
||||
|
||||
@protected
|
||||
Map<String, SubtitleTrack> snapshotExternalSubtitleMetadata() =>
|
||||
Map<String, SubtitleTrack>.of(_externalSubtitleMetadataByUri);
|
||||
Map<String, List<SubtitleTrack>> snapshotExternalSubtitleMetadata() =>
|
||||
Map<String, List<SubtitleTrack>>.of(_externalSubtitleMetadataByUri);
|
||||
|
||||
@protected
|
||||
void restoreExternalSubtitleMetadata(Map<String, SubtitleTrack> snapshot) {
|
||||
_externalSubtitleMetadataByUri = Map<String, SubtitleTrack>.of(snapshot);
|
||||
void restoreExternalSubtitleMetadata(Map<String, List<SubtitleTrack>> snapshot) {
|
||||
_externalSubtitleMetadataByUri = Map<String, List<SubtitleTrack>>.of(snapshot);
|
||||
}
|
||||
|
||||
@protected
|
||||
|
||||
@@ -124,6 +124,7 @@ class PlayerNative extends PlayerBase {
|
||||
?.map((subtitle) => subtitle.uri)
|
||||
.whereType<String>()
|
||||
.where((uri) => uri.isNotEmpty)
|
||||
.toSet()
|
||||
.map((uri) => _escapePathListEntry(uri, separator))
|
||||
.toList();
|
||||
if (escapedUris == null || escapedUris.isEmpty) return null;
|
||||
|
||||
@@ -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<bool> deferTranscodeSubtitleSelection({
|
||||
required TrackManager trackManager,
|
||||
required MediaSubtitleTrack sourceTrack,
|
||||
required PlaybackSubtitleSidecar sourceSidecar,
|
||||
required int sourceStreamId,
|
||||
required Future<void> 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<bool> _selectDirectPlaySourceSubtitleLocally(
|
||||
Future<bool> _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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
||||
|
||||
@@ -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<PlaybackSubtitleSidecar> subtitleSidecars;
|
||||
final bool isOffline;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class PlaybackSubtitleSelection {
|
||||
final SubtitleTrack? secondaryTrack;
|
||||
final int? secondarySourceStreamId;
|
||||
final PlaybackSubtitleSidecar? secondarySidecar;
|
||||
final List<PlaybackSubtitleSidecar> 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<SubtitleTrack> get sidecarsAtOpen {
|
||||
final tracks = <SubtitleTrack>[];
|
||||
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 = <SubtitleTrack>{};
|
||||
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 <MediaSubtitleTrack>[],
|
||||
);
|
||||
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 = <PlaybackSubtitleSidecar>{};
|
||||
@@ -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<SubtitleTrack> nativeTracks,
|
||||
required List<MediaSubtitleTrack> 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) {
|
||||
|
||||
+62
-112
@@ -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<String, String> 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() ?? '<empty>';
|
||||
appLogger.i(
|
||||
'Transcode decision [${decisionResponse.statusCode}] body: '
|
||||
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
||||
);
|
||||
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
|
||||
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<PlaybackInitializationResult> 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<PlaybackSubtitleSidecar> _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<PlaybackSubtitleSidecar> _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 = <PlaybackSubtitleSidecar>[];
|
||||
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<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
||||
return _buildTranscodeSidecarSubtitles(mediaInfo).map((sidecar) => sidecar.track).toList(growable: false);
|
||||
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) {
|
||||
return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl);
|
||||
}
|
||||
|
||||
/// Build list of external subtitle tracks from media info
|
||||
|
||||
@@ -56,7 +56,6 @@ class TrackManager {
|
||||
bool waitingForExternalSubsTrackSelection = false;
|
||||
bool _externalSubtitleAddsInFlight = false;
|
||||
bool _isApplyingTrackSelection = false;
|
||||
int? _applyingSelectionGeneration;
|
||||
Completer<void>? _selectionIdleCompleter;
|
||||
Future<void>? _activePlayerMutationDrain;
|
||||
List<SubtitleTrack> _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<void>();
|
||||
_selectionIdleCompleter = idleCompleter;
|
||||
try {
|
||||
@@ -294,7 +337,6 @@ class TrackManager {
|
||||
return false;
|
||||
} finally {
|
||||
_isApplyingTrackSelection = false;
|
||||
_applyingSelectionGeneration = null;
|
||||
if (identical(_selectionIdleCompleter, idleCompleter)) {
|
||||
_selectionIdleCompleter = null;
|
||||
idleCompleter.complete();
|
||||
|
||||
@@ -85,46 +85,62 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
|
||||
List<MediaSubtitleTrack>? 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<SubtitleTrack>? 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<SubtitleTrack>(
|
||||
@@ -720,7 +762,11 @@ class TrackSelectionService {
|
||||
/// Priority 3: User profile subtitle mode
|
||||
/// Priority 4: Default track
|
||||
/// Priority 5: Off
|
||||
TrackSelectionResult<SubtitleTrack> 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<SubtitleTrack>? selectSubtitleTrack(
|
||||
List<SubtitleTrack> 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)
|
||||
|
||||
+130
-32
@@ -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 = <MethodCall>[];
|
||||
|
||||
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<Object?, Object?>.from(openCall.arguments as Map);
|
||||
final external = args['externalSubtitles'] as List;
|
||||
final subtitle = Map<Object?, Object?>.from(external.single as Map);
|
||||
final external = (args['externalSubtitles'] as List)
|
||||
.map((entry) => Map<Object?, Object?>.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 = <MethodCall>[];
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<MediaSubtitleTrack> 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),
|
||||
|
||||
@@ -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<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
||||
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> 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 = <http.Request>[];
|
||||
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 = <http.Request>[];
|
||||
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', () {
|
||||
|
||||
@@ -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<void>();
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
),
|
||||
);
|
||||
var blockFirstAudioSelection = true;
|
||||
player.onSelectAudioTrack = (_) {
|
||||
if (!blockFirstAudioSelection) return Future<void>.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', () {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user