@@ -55,6 +55,9 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
|
|||||||
import androidx.media3.exoplayer.audio.AudioSink
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
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.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||||
@@ -308,6 +311,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
// External subtitles added dynamically
|
// External subtitles added dynamically
|
||||||
private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
|
private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
|
||||||
private val externalSubtitleUris = mutableListOf<String>()
|
private val externalSubtitleUris = mutableListOf<String>()
|
||||||
|
private val externalSubtitleContainerUris = mutableListOf<String>()
|
||||||
|
private var playbackMediaSourceFactory: DefaultMediaSourceFactory? = null
|
||||||
private var currentMediaUri: String? = null
|
private var currentMediaUri: String? = null
|
||||||
private var currentHeaders: Map<String, String>? = null
|
private var currentHeaders: Map<String, String>? = null
|
||||||
private var currentMediaIsLive: Boolean = false
|
private var currentMediaIsLive: Boolean = false
|
||||||
@@ -660,6 +665,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
|
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
|
||||||
.setSubtitleParserFactory(assParserFactory)
|
.setSubtitleParserFactory(assParserFactory)
|
||||||
|
playbackMediaSourceFactory = mediaSourceFactory
|
||||||
|
|
||||||
// Wrap text renderers with subtitle delay support
|
// Wrap text renderers with subtitle delay support
|
||||||
val wrappedRenderersFactory = RenderersFactory { eventHandler, videoListener, audioListener, textOutput, metadataOutput ->
|
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"
|
"lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction"
|
||||||
)
|
)
|
||||||
|
|
||||||
player.setMediaItem(buildMediaItem(uri), savedPosition)
|
setCurrentMediaSource(player, uri, savedPosition)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = savedPlayWhenReady
|
player.playWhenReady = savedPlayWhenReady
|
||||||
return true
|
return true
|
||||||
@@ -1626,6 +1632,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
val isExternal = format.id?.startsWith("external_") == true
|
val isExternal = format.id?.startsWith("external_") == true
|
||||||
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
|
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
|
||||||
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
|
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")
|
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),
|
"default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0),
|
||||||
"forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0),
|
"forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0),
|
||||||
"selected" to isSelected,
|
"selected" to isSelected,
|
||||||
"external" to isExternal,
|
"external" to (isExternal || isContainer),
|
||||||
"external-filename" to externalUri
|
"container" to isContainer,
|
||||||
|
"external-filename" to (externalUri ?: containerUri)
|
||||||
)
|
)
|
||||||
trackList.add(track)
|
trackList.add(track)
|
||||||
|
|
||||||
@@ -2337,6 +2346,34 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
return mediaItemBuilder.build()
|
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? {
|
private fun selectedAudioFormat(): Format? {
|
||||||
val player = exoPlayer ?: return null
|
val player = exoPlayer ?: return null
|
||||||
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
|
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
|
||||||
@@ -2887,6 +2924,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
externalSubtitles.clear()
|
externalSubtitles.clear()
|
||||||
externalSubtitleUris.clear()
|
externalSubtitleUris.clear()
|
||||||
|
externalSubtitleContainerUris.clear()
|
||||||
lastSubtitleCues = emptyList()
|
lastSubtitleCues = emptyList()
|
||||||
hadSelectedTextTrack = false
|
hadSelectedTextTrack = false
|
||||||
audioTrackGroupMap.clear()
|
audioTrackGroupMap.clear()
|
||||||
@@ -2895,9 +2933,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
selectedSubtitleTrackId = null
|
selectedSubtitleTrackId = null
|
||||||
pendingDvTrackRestore = null
|
pendingDvTrackRestore = null
|
||||||
|
|
||||||
// Build external subtitle configurations (attached to MediaItem before prepare)
|
// Build external subtitle sources before prepare. Container sidecars are
|
||||||
externalSubtitleList?.forEachIndexed { index, sub ->
|
// filtered to text tracks and merged with the primary source; standalone
|
||||||
val subUri = sub["uri"] as? String ?: return@forEachIndexed
|
// 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 title = sub["title"] as? String
|
||||||
val language = sub["language"] as? String
|
val language = sub["language"] as? String
|
||||||
val codec = sub["codec"] as? String
|
val codec = sub["codec"] as? String
|
||||||
@@ -2940,10 +2989,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
)
|
)
|
||||||
emitSeekable(false, force = true)
|
emitSeekable(false, force = true)
|
||||||
|
|
||||||
val mediaItem = buildMediaItem(uri)
|
|
||||||
|
|
||||||
exoPlayer?.apply {
|
exoPlayer?.apply {
|
||||||
setMediaItem(mediaItem, startPositionMs)
|
setCurrentMediaSource(this, uri, startPositionMs)
|
||||||
prepare()
|
prepare()
|
||||||
playWhenReady = autoPlay
|
playWhenReady = autoPlay
|
||||||
}
|
}
|
||||||
@@ -3174,8 +3221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
clearTextOverrides = true
|
clearTextOverrides = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val mediaItem = buildMediaItem(uri)
|
setCurrentMediaSource(player, uri, savedPosition)
|
||||||
player.setMediaItem(mediaItem, savedPosition)
|
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = savedPlayWhenReady
|
player.playWhenReady = savedPlayWhenReady
|
||||||
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
|
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
|
selectedSubtitleTrackId = null
|
||||||
|
|
||||||
val mediaItem = buildMediaItem(mediaUri)
|
setCurrentMediaSource(player, mediaUri, savedPosition)
|
||||||
player.setMediaItem(mediaItem, savedPosition)
|
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = savedPlayWhenReady
|
player.playWhenReady = savedPlayWhenReady
|
||||||
} else {
|
} else {
|
||||||
@@ -3748,6 +3793,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
trackSelector = null
|
trackSelector = null
|
||||||
httpDataSourceFactory = null
|
httpDataSourceFactory = null
|
||||||
dataSourceFactory = null
|
dataSourceFactory = null
|
||||||
|
playbackMediaSourceFactory = null
|
||||||
assHandler?.release()
|
assHandler?.release()
|
||||||
assHandler = null
|
assHandler = null
|
||||||
|
|
||||||
|
|||||||
@@ -1258,6 +1258,7 @@ class ExoPlayerPlugin :
|
|||||||
val escapedUris = externalSubtitles.orEmpty()
|
val escapedUris = externalSubtitles.orEmpty()
|
||||||
.mapNotNull { it["uri"] as? String }
|
.mapNotNull { it["uri"] as? String }
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
.map(::escapeMpvPathListEntry)
|
.map(::escapeMpvPathListEntry)
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
|
|||||||
@@ -585,6 +585,31 @@ class ExoPlayerPluginTest {
|
|||||||
core.dispose()
|
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
|
@Test
|
||||||
fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() {
|
fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() {
|
||||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
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 isDefault,
|
||||||
@Default(false) bool isForced,
|
@Default(false) bool isForced,
|
||||||
@Default(false) bool isExternal,
|
@Default(false) bool isExternal,
|
||||||
|
@Default(false) bool isContainer,
|
||||||
String? uri,
|
String? uri,
|
||||||
}) = _SubtitleTrack;
|
}) = _SubtitleTrack;
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ sealed class SubtitleTrack with _$SubtitleTrack {
|
|||||||
String? codec,
|
String? codec,
|
||||||
bool isDefault = false,
|
bool isDefault = false,
|
||||||
bool isForced = false,
|
bool isForced = false,
|
||||||
|
bool isContainer = false,
|
||||||
}) => SubtitleTrack(
|
}) => SubtitleTrack(
|
||||||
id: 'external:$uri',
|
id: 'external:$uri',
|
||||||
title: title,
|
title: title,
|
||||||
@@ -79,6 +81,7 @@ sealed class SubtitleTrack with _$SubtitleTrack {
|
|||||||
isDefault: isDefault,
|
isDefault: isDefault,
|
||||||
isForced: isForced,
|
isForced: isForced,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
|
isContainer: isContainer,
|
||||||
uri: uri,
|
uri: uri,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+21
-18
@@ -789,7 +789,7 @@ as bool,
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$SubtitleTrack {
|
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
|
/// Create a copy of SubtitleTrack
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -800,16 +800,16 @@ $SubtitleTrackCopyWith<SubtitleTrack> get copyWith => _$SubtitleTrackCopyWithImp
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $SubtitleTrackCopyWith(SubtitleTrack value, $Res Function(SubtitleTrack) _then) = _$SubtitleTrackCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SubtitleTrack
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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
|
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 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,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,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 bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
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) {
|
switch (_that) {
|
||||||
case _SubtitleTrack() when $default != null:
|
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();
|
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) {
|
switch (_that) {
|
||||||
case _SubtitleTrack():
|
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`
|
/// 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) {
|
switch (_that) {
|
||||||
case _SubtitleTrack() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -982,7 +983,7 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,
|
|||||||
|
|
||||||
|
|
||||||
class _SubtitleTrack extends SubtitleTrack {
|
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;
|
@override final String id;
|
||||||
@@ -992,6 +993,7 @@ class _SubtitleTrack extends SubtitleTrack {
|
|||||||
@override@JsonKey() final bool isDefault;
|
@override@JsonKey() final bool isDefault;
|
||||||
@override@JsonKey() final bool isForced;
|
@override@JsonKey() final bool isForced;
|
||||||
@override@JsonKey() final bool isExternal;
|
@override@JsonKey() final bool isExternal;
|
||||||
|
@override@JsonKey() final bool isContainer;
|
||||||
@override final String? uri;
|
@override final String? uri;
|
||||||
|
|
||||||
/// Create a copy of SubtitleTrack
|
/// Create a copy of SubtitleTrack
|
||||||
@@ -1004,16 +1006,16 @@ _$SubtitleTrackCopyWith<_SubtitleTrack> get copyWith => __$SubtitleTrackCopyWith
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$SubtitleTrackCopyWith(_SubtitleTrack value, $Res Function(_SubtitleTrack) _then) = __$SubtitleTrackCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SubtitleTrack
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_SubtitleTrack(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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
|
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 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,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,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 bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
'isLive': isLive,
|
'isLive': isLive,
|
||||||
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
|
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
|
||||||
'externalSubtitles': externalSubtitles
|
'externalSubtitles': externalSubtitles
|
||||||
.where((s) => s.uri != null)
|
.where((s) => s.uri?.isNotEmpty == true)
|
||||||
.map(
|
.map(
|
||||||
(s) => {
|
(s) => {
|
||||||
'uri': s.uri,
|
'uri': s.uri,
|
||||||
@@ -189,6 +189,7 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
'codec': s.codec,
|
'codec': s.codec,
|
||||||
'isDefault': s.isDefault,
|
'isDefault': s.isDefault,
|
||||||
'isForced': s.isForced,
|
'isForced': s.isForced,
|
||||||
|
'isContainer': s.isContainer,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
Duration? _timelineDuration;
|
Duration? _timelineDuration;
|
||||||
int _nextPropId = 0;
|
int _nextPropId = 0;
|
||||||
final Map<int, String> _propIdToName = {};
|
final Map<int, String> _propIdToName = {};
|
||||||
Map<String, SubtitleTrack> _externalSubtitleMetadataByUri = const {};
|
Map<String, List<SubtitleTrack>> _externalSubtitleMetadataByUri = const {};
|
||||||
bool _primaryMediaLoadStarted = false;
|
bool _primaryMediaLoadStarted = false;
|
||||||
bool _primaryMediaReadyEmitted = false;
|
bool _primaryMediaReadyEmitted = false;
|
||||||
|
|
||||||
@@ -499,6 +499,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
final subtitleTracks = <SubtitleTrack>[];
|
final subtitleTracks = <SubtitleTrack>[];
|
||||||
String? selectedAudioId;
|
String? selectedAudioId;
|
||||||
String? selectedSubtitleId;
|
String? selectedSubtitleId;
|
||||||
|
final containerMetadataIndexes = <String, int>{};
|
||||||
|
|
||||||
for (final track in trackList) {
|
for (final track in trackList) {
|
||||||
if (track is! Map) continue;
|
if (track is! Map) continue;
|
||||||
@@ -511,6 +512,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
final selected = track['selected'] == true;
|
final selected = track['selected'] == true;
|
||||||
|
|
||||||
if (type == 'audio') {
|
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;
|
if (selected) selectedAudioId = id;
|
||||||
audioTracks.add(
|
audioTracks.add(
|
||||||
AudioTrack(
|
AudioTrack(
|
||||||
@@ -527,20 +535,43 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
if (selected) selectedSubtitleId = id;
|
if (selected) selectedSubtitleId = id;
|
||||||
final rawCodec = track['codec'];
|
final rawCodec = track['codec'];
|
||||||
final codec = rawCodec is String ? rawCodec : null;
|
final codec = rawCodec is String ? rawCodec : null;
|
||||||
|
final rawTitle = track['title'];
|
||||||
|
final rawLanguage = track['lang'];
|
||||||
final rawExternalFilename = track['external-filename'];
|
final rawExternalFilename = track['external-filename'];
|
||||||
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
|
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
|
||||||
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
|
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
|
||||||
final rawTitle = track['title'];
|
final isContainer =
|
||||||
final rawLanguage = track['lang'];
|
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(
|
subtitleTracks.add(
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
id: id,
|
id: id,
|
||||||
title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
|
// mpv may synthesize a container track title from the signed
|
||||||
language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
|
// source filename. Source-catalog metadata is both safer and more
|
||||||
codec: externalMetadata?.codec ?? codec,
|
// accurate there, including on builds that drop disposition flags.
|
||||||
isDefault: externalMetadata?.isDefault ?? (track['default'] == true),
|
// Ordinary sidecars still fall back to metadata reported by mpv.
|
||||||
isForced: externalMetadata?.isForced ?? (track['forced'] == true),
|
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,
|
isExternal: track['external'] == true,
|
||||||
|
isContainer: isContainer,
|
||||||
uri: externalFilename,
|
uri: externalFilename,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -599,23 +630,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
|
|
||||||
@protected
|
@protected
|
||||||
void setExternalSubtitleMetadata(List<SubtitleTrack>? externalSubtitles) {
|
void setExternalSubtitleMetadata(List<SubtitleTrack>? externalSubtitles) {
|
||||||
final metadataByUri = <String, SubtitleTrack>{};
|
final metadataByUri = <String, List<SubtitleTrack>>{};
|
||||||
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
|
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
|
||||||
final uri = subtitle.uri;
|
final uri = subtitle.uri;
|
||||||
if (uri != null && uri.isNotEmpty) {
|
if (uri != null && uri.isNotEmpty) {
|
||||||
metadataByUri[uri] = subtitle;
|
(metadataByUri[uri] ??= <SubtitleTrack>[]).add(subtitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_externalSubtitleMetadataByUri = metadataByUri;
|
_externalSubtitleMetadataByUri = metadataByUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
Map<String, SubtitleTrack> snapshotExternalSubtitleMetadata() =>
|
Map<String, List<SubtitleTrack>> snapshotExternalSubtitleMetadata() =>
|
||||||
Map<String, SubtitleTrack>.of(_externalSubtitleMetadataByUri);
|
Map<String, List<SubtitleTrack>>.of(_externalSubtitleMetadataByUri);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void restoreExternalSubtitleMetadata(Map<String, SubtitleTrack> snapshot) {
|
void restoreExternalSubtitleMetadata(Map<String, List<SubtitleTrack>> snapshot) {
|
||||||
_externalSubtitleMetadataByUri = Map<String, SubtitleTrack>.of(snapshot);
|
_externalSubtitleMetadataByUri = Map<String, List<SubtitleTrack>>.of(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class PlayerNative extends PlayerBase {
|
|||||||
?.map((subtitle) => subtitle.uri)
|
?.map((subtitle) => subtitle.uri)
|
||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.where((uri) => uri.isNotEmpty)
|
.where((uri) => uri.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
.map((uri) => _escapePathListEntry(uri, separator))
|
.map((uri) => _escapePathListEntry(uri, separator))
|
||||||
.toList();
|
.toList();
|
||||||
if (escapedUris == null || escapedUris.isEmpty) return null;
|
if (escapedUris == null || escapedUris.isEmpty) return null;
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
part of '../../video_player_screen.dart';
|
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 {
|
extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||||
void _clearEpisodeLoadingFlags() {
|
void _clearEpisodeLoadingFlags() {
|
||||||
if (!_isLoadingNext && !_isLoadingPrevious) return;
|
if (!_isLoadingNext && !_isLoadingPrevious) return;
|
||||||
@@ -198,7 +218,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
|
|
||||||
if (newSubtitleChoice != null && newMediaIndex == null && newPreset == null && newAudioStreamId == null) {
|
if (newSubtitleChoice != null && newMediaIndex == null && newPreset == null && newAudioStreamId == null) {
|
||||||
try {
|
try {
|
||||||
final selected = await _selectDirectPlaySourceSubtitleLocally(
|
final selected = await _selectSourceSubtitleLocally(
|
||||||
currentPlayer,
|
currentPlayer,
|
||||||
newSubtitleChoice,
|
newSubtitleChoice,
|
||||||
shouldContinue: isCurrentSourceSwitch,
|
shouldContinue: isCurrentSourceSwitch,
|
||||||
@@ -302,12 +322,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _selectDirectPlaySourceSubtitleLocally(
|
Future<bool> _selectSourceSubtitleLocally(
|
||||||
Player currentPlayer,
|
Player currentPlayer,
|
||||||
PlaybackSourceSubtitleChoice choice, {
|
PlaybackSourceSubtitleChoice choice, {
|
||||||
required bool Function() shouldContinue,
|
required bool Function() shouldContinue,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isTranscoding) return false;
|
|
||||||
if (choice.isOff) {
|
if (choice.isOff) {
|
||||||
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||||
if (!shouldContinue()) return false;
|
if (!shouldContinue()) return false;
|
||||||
@@ -331,15 +350,30 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
if (sourceTrack == null) return false;
|
if (sourceTrack == null) return false;
|
||||||
|
|
||||||
final nativeTracks = currentPlayer.state.tracks.subtitle;
|
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,
|
sourceTrack: sourceTrack,
|
||||||
nativeTracks: nativeTracks,
|
nativeTracks: nativeTracks,
|
||||||
allSourceTracks: info.subtitleTracks,
|
allSourceTracks: info.subtitleTracks,
|
||||||
isResolvedSidecar: _sourceSubtitleSidecarIdsForControls().contains(sourceStreamId),
|
isResolvedSidecar: sourceSidecar != null,
|
||||||
currentSourceStreamId: _playbackSession?.subtitleSelection.primarySourceStreamId,
|
isContainerSidecar: sourceSidecar?.track.isContainer == true,
|
||||||
|
currentSourceStreamId: session?.subtitleSelection.primarySourceStreamId,
|
||||||
selectedNativeTrack: currentPlayer.state.track.subtitle,
|
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);
|
await currentPlayer.selectSubtitleTrack(nativeTrack);
|
||||||
if (!shouldContinue()) return false;
|
if (!shouldContinue()) return false;
|
||||||
@@ -420,6 +454,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
final previousMetadata = _currentMetadata;
|
final previousMetadata = _currentMetadata;
|
||||||
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
|
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
|
||||||
final previousPartId = _currentMediaInfo?.partId;
|
final previousPartId = _currentMediaInfo?.partId;
|
||||||
|
final previousMediaSourceId = _currentMediaInfo?.mediaSourceId;
|
||||||
final previousHasFirstFrame = _hasFirstFrame.value;
|
final previousHasFirstFrame = _hasFirstFrame.value;
|
||||||
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
|
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
|
||||||
final previousHasFatalPlaybackError = _hasFatalPlaybackError;
|
final previousHasFatalPlaybackError = _hasFatalPlaybackError;
|
||||||
@@ -497,6 +532,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
isOffline: _offlineLibraryMode,
|
isOffline: _offlineLibraryMode,
|
||||||
routeKind: VideoPlayerRouteKind.vod,
|
routeKind: VideoPlayerRouteKind.vod,
|
||||||
);
|
);
|
||||||
|
final preservesRequestedSubtitleSource =
|
||||||
|
!isItemChange &&
|
||||||
|
targetMediaIndex == _effectiveSelectedMediaIndex &&
|
||||||
|
(selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId);
|
||||||
|
final initializationSubtitleTrack = preservesRequestedSubtitleSource
|
||||||
|
? currentSubtitleTrack
|
||||||
|
: PlaybackSubtitleResolver.preferenceWithoutSourceIdentity(currentSubtitleTrack);
|
||||||
try {
|
try {
|
||||||
// Eager identity-only: the loading UI shows the new title immediately,
|
// Eager identity-only: the loading UI shows the new title immediately,
|
||||||
// while the selection/source state flips with the session commit at
|
// while the selection/source state flips with the session commit at
|
||||||
@@ -533,7 +575,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
offlineLibraryMode: _offlineLibraryMode,
|
offlineLibraryMode: _offlineLibraryMode,
|
||||||
qualityPreset: targetQualityPreset,
|
qualityPreset: targetQualityPreset,
|
||||||
selectedAudioStreamId: targetAudioStreamId,
|
selectedAudioStreamId: targetAudioStreamId,
|
||||||
preferredSubtitleTrack: currentSubtitleTrack,
|
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
);
|
);
|
||||||
@@ -553,6 +595,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
preferredAudioTrack: currentAudioTrack,
|
preferredAudioTrack: currentAudioTrack,
|
||||||
preferredSubtitleTrack: currentSubtitleTrack,
|
preferredSubtitleTrack: currentSubtitleTrack,
|
||||||
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
||||||
|
preserveSubtitleSourceIdentity:
|
||||||
|
result.mediaInfo != null &&
|
||||||
|
((previousMediaSourceId != null && previousMediaSourceId == result.mediaInfo!.mediaSourceId) ||
|
||||||
|
(previousPartId != null && previousPartId == result.mediaInfo!.partId)),
|
||||||
);
|
);
|
||||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitleTrack? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||||
|
bool preserveSubtitleSourceIdentity = true,
|
||||||
}) async {
|
}) async {
|
||||||
await _waitForProfileSettingsIfNeeded();
|
await _waitForProfileSettingsIfNeeded();
|
||||||
if (!mounted) return const PlaybackSubtitleSelection.off();
|
if (!mounted) return const PlaybackSubtitleSelection.off();
|
||||||
@@ -97,6 +98,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
preferredAudioTrack: preferredAudioTrack,
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||||
|
preserveSourceIdentity: preserveSubtitleSourceIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,24 +330,37 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
|||||||
if (preferred == null) return null;
|
if (preferred == null) return null;
|
||||||
if (preferred.id == SubtitleTrack.off.id) return -1;
|
if (preferred.id == SubtitleTrack.off.id) return -1;
|
||||||
|
|
||||||
|
var semanticPreference = preferred;
|
||||||
const sourcePrefix = 'source:';
|
const sourcePrefix = 'source:';
|
||||||
if (preferred.id.startsWith(sourcePrefix)) {
|
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));
|
final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length));
|
||||||
if (explicit != null) {
|
if (explicit != null) {
|
||||||
final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull;
|
final exactSource = mediaInfo.subtitleTracks.where((track) => track.id == explicit).firstOrNull;
|
||||||
if (exactSource != null) {
|
if (exactSource != null) {
|
||||||
// A source id is authoritative only within one item. When semantic
|
// A source id is authoritative only within one item. When semantic
|
||||||
// metadata is available, reject a coincidentally reused episode id.
|
// metadata is available, prefer the best current-source row so a
|
||||||
final exactMatch = findPlexTrackForMpvSubtitle(preferred, [exactSource]);
|
// reused stream index cannot override a better title/codec match.
|
||||||
|
final semanticMatch = findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks);
|
||||||
final hasLanguage = preferred.language?.isNotEmpty ?? false;
|
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 explicit;
|
||||||
}
|
}
|
||||||
|
return semanticMatch?.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return findPlexTrackForMpvSubtitle(preferred, mediaInfo.subtitleTracks)?.id;
|
return findPlexTrackForMpvSubtitle(semanticPreference, mediaInfo.subtitleTracks)?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
|
||||||
|
|||||||
@@ -70,12 +70,15 @@ class PlaybackInitializationOptions {
|
|||||||
///
|
///
|
||||||
/// [sourceStreamId] links the playable URI back to the authoritative server
|
/// [sourceStreamId] links the playable URI back to the authoritative server
|
||||||
/// subtitle catalog. It is nullable for legacy/offline files whose filename
|
/// 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 {
|
class PlaybackSubtitleSidecar {
|
||||||
final int? sourceStreamId;
|
final int? sourceStreamId;
|
||||||
final SubtitleTrack track;
|
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.
|
/// Reason the transcode branch fell back to direct play.
|
||||||
@@ -93,8 +96,8 @@ class PlaybackInitializationResult {
|
|||||||
final String? videoUrl;
|
final String? videoUrl;
|
||||||
final MediaSourceInfo? mediaInfo;
|
final MediaSourceInfo? mediaInfo;
|
||||||
|
|
||||||
/// Complete sidecar catalog for this source. Callers must resolve the active
|
/// Complete sidecar catalog for this source. Callers resolve the active
|
||||||
/// subtitle choice and attach only the selected sidecar(s) at open time.
|
/// subtitle choice and also attach sidecars marked for preloading.
|
||||||
final List<PlaybackSubtitleSidecar> subtitleSidecars;
|
final List<PlaybackSubtitleSidecar> subtitleSidecars;
|
||||||
final bool isOffline;
|
final bool isOffline;
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class PlaybackSubtitleSelection {
|
|||||||
final SubtitleTrack? secondaryTrack;
|
final SubtitleTrack? secondaryTrack;
|
||||||
final int? secondarySourceStreamId;
|
final int? secondarySourceStreamId;
|
||||||
final PlaybackSubtitleSidecar? secondarySidecar;
|
final PlaybackSubtitleSidecar? secondarySidecar;
|
||||||
|
final List<PlaybackSubtitleSidecar> preloadedSidecars;
|
||||||
|
|
||||||
const PlaybackSubtitleSelection({
|
const PlaybackSubtitleSelection({
|
||||||
required this.primaryTrack,
|
required this.primaryTrack,
|
||||||
@@ -49,9 +50,10 @@ class PlaybackSubtitleSelection {
|
|||||||
this.secondaryTrack,
|
this.secondaryTrack,
|
||||||
this.secondarySourceStreamId,
|
this.secondarySourceStreamId,
|
||||||
this.secondarySidecar,
|
this.secondarySidecar,
|
||||||
|
this.preloadedSidecars = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const PlaybackSubtitleSelection.off()
|
const PlaybackSubtitleSelection.off({this.preloadedSidecars = const []})
|
||||||
: primaryTrack = SubtitleTrack.off,
|
: primaryTrack = SubtitleTrack.off,
|
||||||
primarySourceStreamId = null,
|
primarySourceStreamId = null,
|
||||||
primarySidecar = null,
|
primarySidecar = null,
|
||||||
@@ -63,36 +65,72 @@ class PlaybackSubtitleSelection {
|
|||||||
|
|
||||||
List<SubtitleTrack> get sidecarsAtOpen {
|
List<SubtitleTrack> get sidecarsAtOpen {
|
||||||
final tracks = <SubtitleTrack>[];
|
final tracks = <SubtitleTrack>[];
|
||||||
final primary = primarySidecar?.track;
|
final added = <SubtitleTrack>{};
|
||||||
if (primary != null) tracks.add(primary);
|
void add(SubtitleTrack? track) {
|
||||||
final secondary = secondarySidecar?.track;
|
if (track != null && added.add(track)) tracks.add(track);
|
||||||
if (secondary != null && secondary.uri != primary?.uri) tracks.add(secondary);
|
}
|
||||||
|
|
||||||
|
for (final sidecar in preloadedSidecars) {
|
||||||
|
add(sidecar.track);
|
||||||
|
}
|
||||||
|
add(primarySidecar?.track);
|
||||||
|
add(secondarySidecar?.track);
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the server subtitle catalog before opening the native player, so
|
/// Resolves the server subtitle catalog before opening the native player,
|
||||||
/// only the active sidecar is part of the open operation.
|
/// combining the active choice with any sidecars marked for preloading.
|
||||||
class PlaybackSubtitleResolver {
|
class PlaybackSubtitleResolver {
|
||||||
const 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(
|
static SubtitleTrack? _sourceBackedPreference(
|
||||||
SubtitleTrack? preferred,
|
SubtitleTrack? preferred,
|
||||||
MediaSourceInfo? mediaInfo,
|
MediaSourceInfo? mediaInfo,
|
||||||
List<_SubtitleCandidate> candidates,
|
List<_SubtitleCandidate> candidates, {
|
||||||
) {
|
required bool preserveSourceIdentity,
|
||||||
|
}) {
|
||||||
if (preferred == null || preferred.id == SubtitleTrack.off.id) return preferred;
|
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(
|
final sourceMatch = findPlexTrackForMpvSubtitle(
|
||||||
preferred,
|
semanticPreference,
|
||||||
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||||
);
|
);
|
||||||
if (sourceMatch == null) return preferred;
|
if (sourceMatch == null) return semanticPreference;
|
||||||
|
|
||||||
for (final candidate in candidates) {
|
for (final candidate in candidates) {
|
||||||
if (candidate.sourceStreamId == sourceMatch.id) return candidate.track;
|
if (candidate.sourceStreamId == sourceMatch.id) return candidate.track;
|
||||||
}
|
}
|
||||||
return preferred;
|
return semanticPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
static PlaybackSubtitleSelection resolve({
|
static PlaybackSubtitleSelection resolve({
|
||||||
@@ -103,6 +141,7 @@ class PlaybackSubtitleResolver {
|
|||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitleTrack? preferredSubtitleTrack,
|
||||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||||
|
bool preserveSourceIdentity = true,
|
||||||
}) {
|
}) {
|
||||||
final candidates = <_SubtitleCandidate>[];
|
final candidates = <_SubtitleCandidate>[];
|
||||||
final matchedSidecars = <PlaybackSubtitleSidecar>{};
|
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 availableTracks = candidates.map((candidate) => candidate.track).toList(growable: false);
|
||||||
final service = TrackSelectionService(
|
final service = TrackSelectionService(
|
||||||
profileSettings: profileSettings,
|
profileSettings: profileSettings,
|
||||||
@@ -135,16 +175,30 @@ class PlaybackSubtitleResolver {
|
|||||||
plexMediaInfo: mediaInfo,
|
plexMediaInfo: mediaInfo,
|
||||||
);
|
);
|
||||||
final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track;
|
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 primaryResult = service.selectSubtitleTrack(availableTracks, primaryPreference, selectedAudio);
|
||||||
final primary = primaryResult.track;
|
final primary = primaryResult?.track;
|
||||||
if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off();
|
if (primary == null || primary.id == SubtitleTrack.off.id) {
|
||||||
|
return PlaybackSubtitleSelection.off(preloadedSidecars: preloadedSidecars);
|
||||||
|
}
|
||||||
|
|
||||||
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
|
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;
|
_SubtitleCandidate? secondaryCandidate;
|
||||||
final secondaryPreference = _sourceBackedPreference(preferredSecondarySubtitleTrack, mediaInfo, candidates);
|
final secondaryPreference = _sourceBackedPreference(
|
||||||
|
preferredSecondarySubtitleTrack,
|
||||||
|
mediaInfo,
|
||||||
|
candidates,
|
||||||
|
preserveSourceIdentity: preserveSourceIdentity,
|
||||||
|
);
|
||||||
if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) {
|
if (secondaryPreference != null && secondaryPreference.id != SubtitleTrack.off.id) {
|
||||||
final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference);
|
final secondary = service.findBestSubtitleMatch(availableTracks, secondaryPreference);
|
||||||
secondaryCandidate = candidates
|
secondaryCandidate = candidates
|
||||||
@@ -159,6 +213,7 @@ class PlaybackSubtitleResolver {
|
|||||||
secondaryTrack: secondaryCandidate?.track,
|
secondaryTrack: secondaryCandidate?.track,
|
||||||
secondarySourceStreamId: secondaryCandidate?.sourceStreamId,
|
secondarySourceStreamId: secondaryCandidate?.sourceStreamId,
|
||||||
secondarySidecar: secondaryCandidate?.sidecar,
|
secondarySidecar: secondaryCandidate?.sidecar,
|
||||||
|
preloadedSidecars: preloadedSidecars,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,26 +235,29 @@ class PlaybackSubtitleResolver {
|
|||||||
isDefault: sourceTrack.selected,
|
isDefault: sourceTrack.selected,
|
||||||
isForced: sourceTrack.forced,
|
isForced: sourceTrack.forced,
|
||||||
isExternal: playable != null,
|
isExternal: playable != null,
|
||||||
|
isContainer: playable?.isContainer ?? false,
|
||||||
uri: playable?.uri,
|
uri: playable?.uri,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a server source row to a track already loaded by direct play.
|
/// Resolve a server source row to a track already loaded by the player.
|
||||||
/// Resolved sidecars only match by their stable URL key (or the current
|
/// Standalone sidecars match only by their stable URL key (or current source
|
||||||
/// source identity); fuzzy language/codec matching must not select a
|
/// identity), while a container sidecar uses normal Plex/native metadata
|
||||||
/// different sidecar that happens to have similar metadata. A server's
|
/// matching across the subtitle tracks extracted from that container.
|
||||||
/// `external delivery` flag is not sufficient to classify a direct-play
|
static SubtitleTrack? nativeTrackForSource({
|
||||||
/// row as a sidecar: Jellyfin applies it to embedded tracks that mpv still
|
|
||||||
/// discovers in the original file.
|
|
||||||
static SubtitleTrack? nativeTrackForDirectPlaySource({
|
|
||||||
required MediaSubtitleTrack sourceTrack,
|
required MediaSubtitleTrack sourceTrack,
|
||||||
required List<SubtitleTrack> nativeTracks,
|
required List<SubtitleTrack> nativeTracks,
|
||||||
required List<MediaSubtitleTrack> allSourceTracks,
|
required List<MediaSubtitleTrack> allSourceTracks,
|
||||||
required bool isResolvedSidecar,
|
required bool isResolvedSidecar,
|
||||||
|
required bool isContainerSidecar,
|
||||||
int? currentSourceStreamId,
|
int? currentSourceStreamId,
|
||||||
SubtitleTrack? selectedNativeTrack,
|
SubtitleTrack? selectedNativeTrack,
|
||||||
}) {
|
}) {
|
||||||
if (isResolvedSidecar) {
|
if (isResolvedSidecar) {
|
||||||
|
if (isContainerSidecar) {
|
||||||
|
final containerTracks = nativeTracks.where((track) => track.isContainer).toList(growable: false);
|
||||||
|
return findMpvTrackForPlexSubtitle(sourceTrack, containerTracks, allPlexTracks: allSourceTracks);
|
||||||
|
}
|
||||||
final key = sourceTrack.key;
|
final key = sourceTrack.key;
|
||||||
if (key != null && key.isNotEmpty) {
|
if (key != null && key.isNotEmpty) {
|
||||||
for (final candidate in nativeTracks) {
|
for (final candidate in nativeTracks) {
|
||||||
|
|||||||
+62
-112
@@ -77,7 +77,6 @@ import 'plex_lyrics_parser.dart';
|
|||||||
import 'plex_mappers.dart';
|
import 'plex_mappers.dart';
|
||||||
import 'plex_playback_mapper.dart';
|
import 'plex_playback_mapper.dart';
|
||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
import 'track_selection_service.dart';
|
|
||||||
|
|
||||||
part 'plex_client/parts/live_tv.dart';
|
part 'plex_client/parts/live_tv.dart';
|
||||||
part 'plex_client/parts/playlists.dart';
|
part 'plex_client/parts/playlists.dart';
|
||||||
@@ -2437,9 +2436,9 @@ class PlexClient
|
|||||||
|
|
||||||
/// Build an HLS VOD transcode stream URL (decision + start path).
|
/// Build an HLS VOD transcode stream URL (decision + start path).
|
||||||
///
|
///
|
||||||
/// Text subtitles selected on the Plex part are segmented as WebVTT,
|
/// Subtitle delivery stays outside the HLS video stream. Callers attach
|
||||||
/// image subtitles are burned because HLS has no bitmap subtitle rendition,
|
/// Plex subtitle sources independently, so changing subtitle tracks never
|
||||||
/// and real external sidecars are still attached separately by callers.
|
/// restarts the video transcode.
|
||||||
///
|
///
|
||||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||||
/// seeks + quality/version/audio switches within one playback so the
|
/// seeks + quality/version/audio switches within one playback so the
|
||||||
@@ -2452,7 +2451,6 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final allParams = _buildTranscodeParams(
|
final allParams = _buildTranscodeParams(
|
||||||
@@ -2463,7 +2461,6 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
return await _runTranscodeDecision(
|
||||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||||
@@ -2521,38 +2518,31 @@ class PlexClient
|
|||||||
required Map<String, String> allParams,
|
required Map<String, String> allParams,
|
||||||
required bool isOriginal,
|
required bool isOriginal,
|
||||||
}) async {
|
}) async {
|
||||||
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
|
||||||
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
||||||
|
|
||||||
final decisionClient = MediaServerHttpClient(
|
final decisionResponse = await _http.get(
|
||||||
connectTimeout: MediaServerTimeouts.connect,
|
decisionEndpoint,
|
||||||
receiveTimeout: MediaServerTimeouts.receive,
|
queryParameters: allParams,
|
||||||
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
|
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>';
|
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
|
||||||
appLogger.i(
|
appLogger.i(
|
||||||
'Transcode decision [${decisionResponse.statusCode}] body: '
|
'Transcode decision [${decisionResponse.statusCode}] body: '
|
||||||
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (decisionResponse.statusCode != 200) {
|
if (decisionResponse.statusCode != 200) {
|
||||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
||||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
String _buildTranscodeStartPathFromParams(
|
||||||
@@ -2580,13 +2570,8 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
||||||
}) {
|
}) {
|
||||||
final isOriginal = preset.isOriginal;
|
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(
|
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
||||||
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
||||||
);
|
);
|
||||||
@@ -2609,13 +2594,7 @@ class PlexClient
|
|||||||
'directStreamAudio': '0',
|
'directStreamAudio': '0',
|
||||||
'mediaBufferSize': '102400',
|
'mediaBufferSize': '102400',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
'subtitles': segmentSubtitle
|
'subtitles': 'none',
|
||||||
? 'segmented'
|
|
||||||
: burnSubtitle
|
|
||||||
? 'burn'
|
|
||||||
: 'none',
|
|
||||||
if (selectedInternalSubtitle != null) 'subtitleStreamID': selectedInternalSubtitle.id.toString(),
|
|
||||||
if (segmentSubtitle) 'advancedSubtitles': 'text',
|
|
||||||
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
||||||
'Accept-Language': 'en',
|
'Accept-Language': 'en',
|
||||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||||
@@ -2644,7 +2623,6 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
||||||
}) {
|
}) {
|
||||||
return _buildTranscodeParams(
|
return _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
@@ -2654,7 +2632,6 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3084,9 +3061,8 @@ class PlexClient
|
|||||||
|
|
||||||
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
|
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
|
||||||
/// then either runs the transcode-decision flow or returns the direct-play
|
/// then either runs the transcode-decision flow or returns the direct-play
|
||||||
/// URL. Keyed subtitle tracks remain external sidecars; selected internal
|
/// URL. Transcoded video stays subtitle-free; every Plex subtitle remains
|
||||||
/// text tracks become segmented WebVTT and image tracks are burned into the
|
/// independently selectable through the returned sidecar catalog.
|
||||||
/// HLS rendition.
|
|
||||||
@override
|
@override
|
||||||
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
|
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
|
||||||
try {
|
try {
|
||||||
@@ -3135,7 +3111,6 @@ class PlexClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
||||||
final selectedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
|
|
||||||
final result = await buildTranscodeStartPath(
|
final result = await buildTranscodeStartPath(
|
||||||
ratingKey: options.metadata.id,
|
ratingKey: options.metadata.id,
|
||||||
mediaIndex: data.selectedMediaIndex,
|
mediaIndex: data.selectedMediaIndex,
|
||||||
@@ -3144,12 +3119,11 @@ class PlexClient
|
|||||||
sessionIdentifier: options.sessionIdentifier!,
|
sessionIdentifier: options.sessionIdentifier!,
|
||||||
transcodeSessionId: options.transcodeSessionId!,
|
transcodeSessionId: options.transcodeSessionId!,
|
||||||
audioStreamId: resolvedAudioId,
|
audioStreamId: resolvedAudioId,
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||||
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
||||||
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo, data.videoUrl!);
|
||||||
return PlaybackInitializationResult(
|
return PlaybackInitializationResult(
|
||||||
availableVersions: data.availableVersions,
|
availableVersions: data.availableVersions,
|
||||||
videoUrl: transcodeUrl,
|
videoUrl: transcodeUrl,
|
||||||
@@ -3249,41 +3223,6 @@ class PlexClient
|
|||||||
return tracks.first.id;
|
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
|
/// Build the absolute URL for an external subtitle track on this Plex
|
||||||
/// server. Returns `null` for tracks that aren't external (no `/library/
|
/// server. Returns `null` for tracks that aren't external (no `/library/
|
||||||
/// streams/{id}` key) or when the server has no auth token.
|
/// streams/{id}` key) or when the server has no auth token.
|
||||||
@@ -3305,20 +3244,10 @@ class PlexClient
|
|||||||
/// `Stream.key` is required here.
|
/// `Stream.key` is required here.
|
||||||
String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
|
String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
|
||||||
if (track.key == null || track.key!.isEmpty) return null;
|
if (track.key == null || track.key!.isEmpty) return null;
|
||||||
final token = config.token;
|
|
||||||
if (token == null) return null;
|
|
||||||
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
||||||
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
|
final url = '${config.baseUrl}${track.key}.$ext?encoding=utf-8';
|
||||||
}
|
final token = config.token;
|
||||||
|
return token == null ? url : '$url&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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
||||||
@@ -3334,21 +3263,42 @@ class PlexClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build subtitle sidecars for Plex transcode playback. Keyed tracks remain
|
SubtitleTrack _containerSubtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
||||||
/// external; the selected internal track is delivered by the HLS rendition.
|
return SubtitleTrack(
|
||||||
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
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 (mediaInfo == null) return const [];
|
||||||
if (config.token == null) {
|
|
||||||
appLogger.w('No auth token available for transcode sidecar subtitles');
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final tracks = <PlaybackSubtitleSidecar>[];
|
final tracks = <PlaybackSubtitleSidecar>[];
|
||||||
for (final sub in mediaInfo.subtitleTracks) {
|
for (final sub in mediaInfo.subtitleTracks) {
|
||||||
try {
|
try {
|
||||||
final url = _buildSidecarSubtitleUrl(sub);
|
final directUrl = _buildSidecarSubtitleUrl(sub);
|
||||||
if (url == null) continue;
|
tracks.add(
|
||||||
tracks.add(PlaybackSubtitleSidecar(sourceStreamId: sub.id, track: _subtitleTrackFromMediaTrack(sub, url)));
|
PlaybackSubtitleSidecar(
|
||||||
|
sourceStreamId: sub.id,
|
||||||
|
track: directUrl == null
|
||||||
|
? _containerSubtitleTrackFromMediaTrack(sub, sourceUrl)
|
||||||
|
: _subtitleTrackFromMediaTrack(sub, directUrl),
|
||||||
|
preload: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
||||||
}
|
}
|
||||||
@@ -3357,8 +3307,8 @@ class PlexClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) {
|
||||||
return _buildTranscodeSidecarSubtitles(mediaInfo).map((sidecar) => sidecar.track).toList(growable: false);
|
return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build list of external subtitle tracks from media info
|
/// Build list of external subtitle tracks from media info
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ class TrackManager {
|
|||||||
bool waitingForExternalSubsTrackSelection = false;
|
bool waitingForExternalSubsTrackSelection = false;
|
||||||
bool _externalSubtitleAddsInFlight = false;
|
bool _externalSubtitleAddsInFlight = false;
|
||||||
bool _isApplyingTrackSelection = false;
|
bool _isApplyingTrackSelection = false;
|
||||||
int? _applyingSelectionGeneration;
|
|
||||||
Completer<void>? _selectionIdleCompleter;
|
Completer<void>? _selectionIdleCompleter;
|
||||||
Future<void>? _activePlayerMutationDrain;
|
Future<void>? _activePlayerMutationDrain;
|
||||||
List<SubtitleTrack> _lastExternalSubtitles = const [];
|
List<SubtitleTrack> _lastExternalSubtitles = const [];
|
||||||
@@ -199,31 +198,61 @@ class TrackManager {
|
|||||||
// ── Track selection ────────────────────────────────────────────────
|
// ── Track selection ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Apply track selection once tracks are available.
|
/// 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() {
|
void applyTrackSelectionWhenReady() {
|
||||||
|
final selectionGeneration = _selectionGeneration;
|
||||||
|
bool selectionIsCurrent() => _isSelectionCurrent(selectionGeneration);
|
||||||
final currentTracks = player.state.tracks;
|
final currentTracks = player.state.tracks;
|
||||||
if (_tracksReadyForSelection(currentTracks)) {
|
if (_tracksReadyForSelection(currentTracks)) {
|
||||||
applyTrackSelection();
|
unawaited(applyTrackSelection());
|
||||||
} else {
|
return;
|
||||||
_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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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) {
|
bool _tracksReadyForSelection(Tracks tracks) {
|
||||||
@@ -231,14 +260,29 @@ class TrackManager {
|
|||||||
if (!hasAnyTracks) return false;
|
if (!hasAnyTracks) return false;
|
||||||
|
|
||||||
final info = mediaInfo;
|
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
|
final nativeSubtitleTracks = tracks.subtitle
|
||||||
// in-place item reload Android clears the old track list before the new
|
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
|
||||||
// demuxed subtitles arrive; applying selection at the first audio-only
|
.toList(growable: false);
|
||||||
// update would treat that temporary empty subtitle list as an explicit
|
final preferred = preferredSubtitleTrack;
|
||||||
// server "off" decision and leave the next episode without selectable subs.
|
final preferredHasSemanticIdentity =
|
||||||
return info.subtitleTracks.isEmpty;
|
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
|
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
||||||
@@ -249,10 +293,10 @@ class TrackManager {
|
|||||||
if (!selectionIsActive()) return false;
|
if (!selectionIsActive()) return false;
|
||||||
|
|
||||||
if (_isApplyingTrackSelection) {
|
if (_isApplyingTrackSelection) {
|
||||||
// Calls from the active generation are already represented by the
|
// A later track-list event can make a same-generation selection materially
|
||||||
// in-flight selection. A replacement generation, however, must wait for
|
// different (notably when subtitles arrive while the five-second audio/rate
|
||||||
// stale work to unwind rather than losing its only selection request.
|
// fallback is still applying). Queue one pass after the current mutation
|
||||||
if (_applyingSelectionGeneration == selectionGeneration) return false;
|
// chain rather than dropping that event.
|
||||||
final activeSelectionDone = _selectionIdleCompleter?.future;
|
final activeSelectionDone = _selectionIdleCompleter?.future;
|
||||||
if (activeSelectionDone == null) return false;
|
if (activeSelectionDone == null) return false;
|
||||||
await activeSelectionDone;
|
await activeSelectionDone;
|
||||||
@@ -261,7 +305,6 @@ class TrackManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isApplyingTrackSelection = true;
|
_isApplyingTrackSelection = true;
|
||||||
_applyingSelectionGeneration = selectionGeneration;
|
|
||||||
final idleCompleter = Completer<void>();
|
final idleCompleter = Completer<void>();
|
||||||
_selectionIdleCompleter = idleCompleter;
|
_selectionIdleCompleter = idleCompleter;
|
||||||
try {
|
try {
|
||||||
@@ -294,7 +337,6 @@ class TrackManager {
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
_isApplyingTrackSelection = false;
|
_isApplyingTrackSelection = false;
|
||||||
_applyingSelectionGeneration = null;
|
|
||||||
if (identical(_selectionIdleCompleter, idleCompleter)) {
|
if (identical(_selectionIdleCompleter, idleCompleter)) {
|
||||||
_selectionIdleCompleter = null;
|
_selectionIdleCompleter = null;
|
||||||
idleCompleter.complete();
|
idleCompleter.complete();
|
||||||
|
|||||||
@@ -85,46 +85,62 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
|
|||||||
List<MediaSubtitleTrack>? allPlexTracks,
|
List<MediaSubtitleTrack>? allPlexTracks,
|
||||||
}) {
|
}) {
|
||||||
if (mpvTracks.isEmpty) return null;
|
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
|
// Keyed subtitles have a stable identity. Do not let a sidecar that has not
|
||||||
if (plexTrack.isExternal && plexTrack.key != null) {
|
// arrived yet fall through to fuzzy language/title scoring.
|
||||||
|
final plexKey = plexTrack.key;
|
||||||
|
if (plexKey != null && plexKey.isNotEmpty) {
|
||||||
for (final mpvTrack in mpvTracks) {
|
for (final mpvTrack in mpvTracks) {
|
||||||
if (mpvTrack.isExternal && mpvTrack.uri != null) {
|
if (mpvTrack.isExternal && mpvTrack.uri?.contains(plexKey) == true) {
|
||||||
// Check if the MPV URI contains the Plex key path
|
return mpvTrack;
|
||||||
if (mpvTrack.uri!.contains(plexTrack.key!)) {
|
|
||||||
return mpvTrack;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For internal subtitles, use scoring based on properties
|
// For internal subtitles, use scoring based on properties
|
||||||
SubtitleTrack? bestMatch;
|
SubtitleTrack? bestMatch;
|
||||||
int bestScore = 0;
|
int bestScore = 0;
|
||||||
|
bool bestMatchUsesContainerOrdinal = false;
|
||||||
|
|
||||||
// Ordinal tiebreaker: precompute position of plexTrack among internal tracks
|
// Ordinal identity: container sidecars expose embedded subtitle tracks as
|
||||||
final internalMpvTracks = allPlexTracks != null ? mpvTracks.where((t) => !t.isExternal).toList() : null;
|
// external media, but retain the source container's subtitle ordering.
|
||||||
final plexOrdinal = allPlexTracks != null
|
final containerPlexTracks = allPlexTracks
|
||||||
? allPlexTracks.where((t) => !t.isExternal).toList().indexOf(plexTrack)
|
?.where((track) => track.key == null || track.key!.isEmpty)
|
||||||
: -1;
|
.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) {
|
for (final mpvTrack in mpvTracks) {
|
||||||
// Skip external tracks when matching internal Plex tracks
|
// A container sidecar's subtitle tracks map to internal Plex streams.
|
||||||
if (!plexTrack.isExternal && mpvTrack.isExternal) continue;
|
if (!plexTrack.isExternal && mpvTrack.isExternal && !mpvTrack.isContainer) continue;
|
||||||
|
|
||||||
final ordinalMatches =
|
final ordinalMatches =
|
||||||
internalMpvTracks != null && plexOrdinal >= 0 && internalMpvTracks.indexOf(mpvTrack) == plexOrdinal;
|
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);
|
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||||
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMatch = mpvTrack;
|
bestMatch = mpvTrack;
|
||||||
|
bestMatchUsesContainerOrdinal = mpvTrack.isContainer && ordinalMatches;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require at least language match for a valid match
|
// Prefer metadata matches. Container sidecars may expose no language/title/
|
||||||
return bestScore >= 10 ? bestMatch : null;
|
// 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
|
/// Find the Plex subtitle track that matches an MPV subtitle track
|
||||||
@@ -134,43 +150,61 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
|
|||||||
List<SubtitleTrack>? allMpvTracks,
|
List<SubtitleTrack>? allMpvTracks,
|
||||||
}) {
|
}) {
|
||||||
if (plexTracks.isEmpty) return null;
|
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) {
|
if (mpvTrack.isExternal && mpvTrack.uri != null) {
|
||||||
for (final plexTrack in plexTracks) {
|
for (final plexTrack in plexTracks) {
|
||||||
if (plexTrack.isExternal && plexTrack.key != null) {
|
final plexKey = plexTrack.key;
|
||||||
if (mpvTrack.uri!.contains(plexTrack.key!)) {
|
if (plexKey != null && plexKey.isNotEmpty && mpvTrack.uri!.contains(plexKey)) {
|
||||||
return plexTrack;
|
return plexTrack;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!mpvTrack.isContainer) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For internal subtitles, use scoring based on properties
|
// For internal subtitles, use scoring based on properties
|
||||||
MediaSubtitleTrack? bestMatch;
|
MediaSubtitleTrack? bestMatch;
|
||||||
int bestScore = 0;
|
int bestScore = 0;
|
||||||
|
bool bestMatchUsesContainerOrdinal = false;
|
||||||
|
|
||||||
// Ordinal tiebreaker: precompute position of mpvTrack among internal tracks
|
// Ordinal identity: container-sidecar tracks map back to source-container
|
||||||
final internalPlexTracks = allMpvTracks != null ? plexTracks.where((t) => !t.isExternal).toList() : null;
|
// streams even though the native player marks their source as external.
|
||||||
final mpvOrdinal = allMpvTracks != null ? allMpvTracks.where((t) => !t.isExternal).toList().indexOf(mpvTrack) : -1;
|
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) {
|
for (final plexTrack in plexTracks) {
|
||||||
// Skip external Plex tracks when matching internal MPV tracks
|
if (mpvIsInternal && plexTrack.isExternal) continue;
|
||||||
if (!mpvTrack.isExternal && plexTrack.isExternal) continue;
|
|
||||||
|
|
||||||
final ordinalMatches =
|
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);
|
final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches);
|
||||||
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMatch = plexTrack;
|
bestMatch = plexTrack;
|
||||||
|
bestMatchUsesContainerOrdinal = mpvTrack.isContainer && ordinalMatches;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require at least language match for a valid match
|
// Prefer metadata matches, with container order as the symmetric fallback
|
||||||
return bestScore >= 10 ? bestMatch : null;
|
// 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
|
/// Find the MPV audio track that matches a Plex audio track
|
||||||
@@ -568,11 +602,19 @@ class TrackSelectionService {
|
|||||||
return SubtitleTrack.off;
|
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;
|
final preferredUri = preferred.uri;
|
||||||
if (preferredUri != null) {
|
if (preferredUri != null) {
|
||||||
for (final track in availableTracks) {
|
final uriMatches = availableTracks.where((track) => track.uri == preferredUri).toList(growable: false);
|
||||||
if (track.uri == preferredUri) return track;
|
if (uriMatches.length == 1) return uriMatches.single;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return findBestTrackMatch<SubtitleTrack>(
|
return findBestTrackMatch<SubtitleTrack>(
|
||||||
@@ -720,7 +762,11 @@ class TrackSelectionService {
|
|||||||
/// Priority 3: User profile subtitle mode
|
/// Priority 3: User profile subtitle mode
|
||||||
/// Priority 4: Default track
|
/// Priority 4: Default track
|
||||||
/// Priority 5: Off
|
/// 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,
|
List<SubtitleTrack> availableTracks,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitleTrack? preferredSubtitleTrack,
|
||||||
AudioTrack? selectedAudioTrack,
|
AudioTrack? selectedAudioTrack,
|
||||||
@@ -735,15 +781,14 @@ class TrackSelectionService {
|
|||||||
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (preferredSubtitleTrack.id.startsWith('source:')) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Trust the server's selected track. Plex computes this from
|
// Priority 2: Trust the server's selected track. Plex computes this from
|
||||||
// account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex.
|
// account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex.
|
||||||
final info = plexMediaInfo;
|
final info = plexMediaInfo;
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
final serverSelectedTrack = availableTracks.isNotEmpty
|
final serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull;
|
||||||
? info.subtitleTracks.where((track) => track.selected).firstOrNull
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (serverSelectedTrack != null) {
|
if (serverSelectedTrack != null) {
|
||||||
final matchedMpvTrack = findMpvTrackForPlexSubtitle(
|
final matchedMpvTrack = findMpvTrackForPlexSubtitle(
|
||||||
@@ -755,6 +800,7 @@ class TrackSelectionService {
|
|||||||
if (matchedMpvTrack != null) {
|
if (matchedMpvTrack != null) {
|
||||||
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected);
|
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected);
|
||||||
}
|
}
|
||||||
|
if (metadata.backend == MediaBackend.plex) return null;
|
||||||
} else if (metadata.backend == MediaBackend.jellyfin) {
|
} else if (metadata.backend == MediaBackend.jellyfin) {
|
||||||
final defaultStreamIndex = info.defaultSubtitleStreamIndex;
|
final defaultStreamIndex = info.defaultSubtitleStreamIndex;
|
||||||
if (defaultStreamIndex == -1) {
|
if (defaultStreamIndex == -1) {
|
||||||
@@ -779,9 +825,11 @@ class TrackSelectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (metadata.backend == MediaBackend.plex && info.subtitleTracks.isNotEmpty) {
|
} 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);
|
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
|
// 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 subtitleResult = selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack);
|
||||||
final selectedSubtitleTrack = subtitleResult.track;
|
if (subtitleResult != null) {
|
||||||
final subtitleName = selectedSubtitleTrack.id == 'no'
|
final selectedSubtitleTrack = subtitleResult.track;
|
||||||
? 'OFF'
|
final subtitleName = selectedSubtitleTrack.id == 'no'
|
||||||
: (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}');
|
? 'OFF'
|
||||||
appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]');
|
: (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}');
|
||||||
if (!canMutatePlayer()) return false;
|
appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]');
|
||||||
final subtitleMutation = player.selectSubtitleTrack(selectedSubtitleTrack);
|
if (!canMutatePlayer()) return false;
|
||||||
onPlayerMutationDispatched?.call(subtitleMutation);
|
final subtitleMutation = player.selectSubtitleTrack(selectedSubtitleTrack);
|
||||||
await subtitleMutation;
|
onPlayerMutationDispatched?.call(subtitleMutation);
|
||||||
if (!canMutatePlayer()) return false;
|
await subtitleMutation;
|
||||||
|
if (!canMutatePlayer()) return false;
|
||||||
|
|
||||||
// Save to Plex if this was user's navigation preference (Priority 1)
|
// Save to Plex if this was user's navigation preference (Priority 1)
|
||||||
if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) {
|
if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) {
|
||||||
onSubtitleTrackChanged(selectedSubtitleTrack);
|
onSubtitleTrackChanged(selectedSubtitleTrack);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appLogger.d('Subtitle selection pending: native tracks have not arrived');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply preferred secondary subtitle track if provided (mpv-only)
|
// 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>[];
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
await withMockPlayerChannels(
|
await withMockPlayerChannels(
|
||||||
@@ -252,18 +252,14 @@ void main() {
|
|||||||
eventChannelName: 'com.plezy/exo_player/events',
|
eventChannelName: 'com.plezy/exo_player/events',
|
||||||
methodHandler: (call) {
|
methodHandler: (call) {
|
||||||
calls.add(call);
|
calls.add(call);
|
||||||
switch (call.method) {
|
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
|
||||||
case 'initialize':
|
|
||||||
return Future.value(true);
|
|
||||||
default:
|
|
||||||
return Future.value(null);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
testBody: () async {
|
testBody: () async {
|
||||||
final player = PlayerAndroid();
|
final player = PlayerAndroid();
|
||||||
try {
|
try {
|
||||||
|
const containerUri = 'https://example.test/movie.mkv';
|
||||||
await player.open(
|
await player.open(
|
||||||
Media('https://example.test/movie.mkv'),
|
Media('https://example.test/transcode.m3u8'),
|
||||||
externalSubtitles: const [
|
externalSubtitles: const [
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
id: 'external-sub',
|
id: 'external-sub',
|
||||||
@@ -275,20 +271,44 @@ void main() {
|
|||||||
isExternal: true,
|
isExternal: true,
|
||||||
uri: 'https://example.test/sub.srt',
|
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 openCall = calls.singleWhere((call) => call.method == 'open');
|
||||||
final args = Map<Object?, Object?>.from(openCall.arguments as Map);
|
final args = Map<Object?, Object?>.from(openCall.arguments as Map);
|
||||||
final external = args['externalSubtitles'] as List;
|
final external = (args['externalSubtitles'] as List)
|
||||||
final subtitle = Map<Object?, Object?>.from(external.single as Map);
|
.map((entry) => Map<Object?, Object?>.from(entry as Map))
|
||||||
|
.toList();
|
||||||
|
|
||||||
expect(subtitle['uri'], 'https://example.test/sub.srt');
|
expect(external, hasLength(3));
|
||||||
expect(subtitle['title'], 'English Forced');
|
expect(external.first['uri'], 'https://example.test/sub.srt');
|
||||||
expect(subtitle['language'], 'eng');
|
expect(external.first['title'], 'English Forced');
|
||||||
expect(subtitle['codec'], 'srt');
|
expect(external.first['isDefault'], isTrue);
|
||||||
expect(subtitle['isDefault'], isTrue);
|
expect(external.first['isForced'], isTrue);
|
||||||
expect(subtitle['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 {
|
} finally {
|
||||||
await player.dispose();
|
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(
|
await withMockPlayerChannels(
|
||||||
methodChannelName: 'com.plezy/mpv_player',
|
methodChannelName: 'com.plezy/mpv_player',
|
||||||
eventChannelName: 'com.plezy/mpv_player/events',
|
eventChannelName: 'com.plezy/mpv_player/events',
|
||||||
|
methodHandler: (call) {
|
||||||
|
calls.add(call);
|
||||||
|
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
|
||||||
|
},
|
||||||
testBody: () async {
|
testBody: () async {
|
||||||
final player = PlayerNative();
|
final player = PlayerNative();
|
||||||
try {
|
try {
|
||||||
const subtitleUri = 'https://example.test/subtitles/en-forced.srt';
|
const subtitleUri = 'https://example.test/movie.mkv?X-Plex-Token=secret';
|
||||||
await player.open(
|
await player.open(
|
||||||
Media('https://example.test/movie.mkv'),
|
Media('https://example.test/transcode.m3u8'),
|
||||||
externalSubtitles: const [
|
externalSubtitles: const [
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
id: 'server-subtitle',
|
id: 'container:1',
|
||||||
uri: subtitleUri,
|
uri: subtitleUri,
|
||||||
title: 'English Forced',
|
title: 'English Dialogue',
|
||||||
language: 'eng',
|
language: 'eng',
|
||||||
codec: 'srt',
|
codec: 'ass',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
isExternal: true,
|
||||||
|
isContainer: true,
|
||||||
|
),
|
||||||
|
SubtitleTrack(
|
||||||
|
id: 'container:2',
|
||||||
|
uri: subtitleUri,
|
||||||
|
title: 'English Signs',
|
||||||
|
language: 'eng',
|
||||||
|
codec: 'ass',
|
||||||
isForced: true,
|
isForced: true,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
|
isContainer: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
expect(_loadfileArgs(calls), [
|
||||||
player.handlePropertyChange('track-list', const [
|
'loadfile',
|
||||||
{'type': 'sub', 'id': '1', 'codec': 'subrip', 'external': true, 'external-filename': subtitleUri},
|
'https://example.test/transcode.m3u8',
|
||||||
|
'replace',
|
||||||
|
'-1',
|
||||||
|
'sub-files=${_fixedLengthPathList([subtitleUri])}',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final subtitle = player.state.tracks.subtitle.single;
|
player.handlePropertyChange('track-list', const [
|
||||||
expect(subtitle.title, 'English Forced');
|
{'type': 'audio', 'id': 'sidecar-audio', 'external': true, 'external-filename': subtitleUri},
|
||||||
expect(subtitle.language, 'eng');
|
{
|
||||||
expect(subtitle.codec, 'srt');
|
'type': 'sub',
|
||||||
expect(subtitle.isDefault, isTrue);
|
'id': '1',
|
||||||
expect(subtitle.isForced, isTrue);
|
'title': 'movie.mkv?X-Plex-Token=secret',
|
||||||
expect(subtitle.uri, subtitleUri);
|
'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 {
|
} finally {
|
||||||
await player.dispose();
|
await player.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart';
|
|||||||
MediaSubtitleTrack _sourceSubtitle(
|
MediaSubtitleTrack _sourceSubtitle(
|
||||||
int id, {
|
int id, {
|
||||||
String language = 'eng',
|
String language = 'eng',
|
||||||
|
String codec = 'srt',
|
||||||
bool forced = false,
|
bool forced = false,
|
||||||
bool selected = false,
|
bool selected = false,
|
||||||
bool external = false,
|
bool external = false,
|
||||||
@@ -20,6 +21,7 @@ MediaSubtitleTrack _sourceSubtitle(
|
|||||||
id: id,
|
id: id,
|
||||||
language: language,
|
language: language,
|
||||||
languageCode: language,
|
languageCode: language,
|
||||||
|
codec: codec,
|
||||||
title: 'Subtitle $id',
|
title: 'Subtitle $id',
|
||||||
selected: selected,
|
selected: selected,
|
||||||
forced: forced,
|
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(
|
return PlaybackSubtitleSidecar(
|
||||||
sourceStreamId: id,
|
sourceStreamId: id,
|
||||||
track: SubtitleTrack.uri(
|
preload: preload,
|
||||||
'https://example.test/subtitles/$id.srt',
|
track: isContainer
|
||||||
title: 'Subtitle $id',
|
? SubtitleTrack(
|
||||||
language: language,
|
id: 'container:$id',
|
||||||
codec: 'srt',
|
title: 'Subtitle $id',
|
||||||
isDefault: isDefault,
|
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() {
|
void main() {
|
||||||
group('direct-play source routing', () {
|
group('source subtitle routing', () {
|
||||||
test('matches an embedded source to its loaded native track', () {
|
test('matches an embedded source to its loaded native track', () {
|
||||||
final source = _sourceSubtitle(2, language: 'eng');
|
final source = _sourceSubtitle(2, language: 'eng');
|
||||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||||
sourceTrack: source,
|
sourceTrack: source,
|
||||||
nativeTracks: const [native],
|
nativeTracks: const [native],
|
||||||
allSourceTracks: [source],
|
allSourceTracks: [source],
|
||||||
isResolvedSidecar: false,
|
isResolvedSidecar: false,
|
||||||
|
isContainerSidecar: false,
|
||||||
),
|
),
|
||||||
native,
|
native,
|
||||||
);
|
);
|
||||||
@@ -81,11 +98,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||||
sourceTrack: source,
|
sourceTrack: source,
|
||||||
nativeTracks: const [other],
|
nativeTracks: const [other],
|
||||||
allSourceTracks: [source],
|
allSourceTracks: [source],
|
||||||
isResolvedSidecar: true,
|
isResolvedSidecar: true,
|
||||||
|
isContainerSidecar: false,
|
||||||
),
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
@@ -117,15 +135,50 @@ void main() {
|
|||||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||||
sourceTrack: source,
|
sourceTrack: source,
|
||||||
nativeTracks: const [native],
|
nativeTracks: const [native],
|
||||||
allSourceTracks: [source],
|
allSourceTracks: [source],
|
||||||
isResolvedSidecar: false,
|
isResolvedSidecar: false,
|
||||||
|
isContainerSidecar: false,
|
||||||
),
|
),
|
||||||
native,
|
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);
|
final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
|
||||||
@@ -160,6 +213,24 @@ void main() {
|
|||||||
expect(result.sidecarsAtOpen, isEmpty);
|
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', () {
|
test('explicit source selection wins over the server default', () {
|
||||||
final mediaInfo = _mediaInfo([
|
final mediaInfo = _mediaInfo([
|
||||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||||
@@ -179,6 +250,82 @@ void main() {
|
|||||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
|
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', () {
|
test('item-change semantic preference selects the matching new sidecar', () {
|
||||||
final result = PlaybackSubtitleResolver.resolve(
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
@@ -240,6 +387,17 @@ void main() {
|
|||||||
expect(result.sidecarsAtOpen, isEmpty);
|
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', () {
|
test('preferred secondary subtitle attaches a second distinct sidecar', () {
|
||||||
final mediaInfo = _mediaInfo([
|
final mediaInfo = _mediaInfo([
|
||||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
_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_kind.dart';
|
||||||
import 'package:plezy/media/media_source_info.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/models/transcode_quality_preset.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
import 'package:plezy/services/plex_api_cache.dart';
|
import 'package:plezy/services/plex_api_cache.dart';
|
||||||
@@ -44,8 +43,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||||
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(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 {
|
test('selectStreams sends audio stream selection with allParts', () async {
|
||||||
@@ -129,6 +131,97 @@ void main() {
|
|||||||
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
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 {
|
test('playback uses metadata availability flags without probing part URLs', () async {
|
||||||
final requests = <http.Request>[];
|
final requests = <http.Request>[];
|
||||||
final client = makeClient((request) async {
|
final client = makeClient((request) async {
|
||||||
@@ -331,16 +424,17 @@ void main() {
|
|||||||
expect(result.selectedMediaIndex, 1);
|
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));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|
||||||
final subtitles = buildTranscodeSubtitles(client, [
|
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(
|
MediaSubtitleTrack(
|
||||||
id: 402,
|
id: 402,
|
||||||
codec: 'srt',
|
codec: 'srt',
|
||||||
languageCode: 'eng',
|
languageCode: 'swe',
|
||||||
|
title: 'External',
|
||||||
selected: false,
|
selected: false,
|
||||||
forced: false,
|
forced: false,
|
||||||
key: '/library/streams/402',
|
key: '/library/streams/402',
|
||||||
@@ -348,63 +442,50 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(subtitles, hasLength(1));
|
expect(subtitles, hasLength(2));
|
||||||
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
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', () {
|
test('tokenless transcode keeps embedded and keyed subtitle sources', () {
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = testPlexClient(
|
||||||
|
serverId: ServerId('server-id'),
|
||||||
|
token: null,
|
||||||
|
handler: (_) async => http.Response('not used', 500),
|
||||||
|
);
|
||||||
addTearDown(client.close);
|
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(
|
final subtitles = client.buildTranscodeSidecarSubtitlesForTesting(
|
||||||
info,
|
mediaInfoWithSubtitles([
|
||||||
const SubtitleTrack(id: 'source:402', language: 'swe'),
|
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(subtitles, hasLength(2));
|
||||||
expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull);
|
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', () {
|
test('video transcode stays subtitle-free while preserving the HLS profile', () {
|
||||||
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', () {
|
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|
||||||
@@ -414,19 +495,12 @@ void main() {
|
|||||||
preset: TranscodeQualityPreset.p720_3mbps,
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
sessionIdentifier: 'session-id',
|
sessionIdentifier: 'session-id',
|
||||||
transcodeSessionId: 'transcode-id',
|
transcodeSessionId: 'transcode-id',
|
||||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
|
||||||
id: 401,
|
|
||||||
codec: 'ass',
|
|
||||||
languageCode: 'eng',
|
|
||||||
selected: true,
|
|
||||||
forced: false,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(params['protocol'], 'hls');
|
expect(params['protocol'], 'hls');
|
||||||
expect(params['subtitles'], 'segmented');
|
expect(params['subtitles'], 'none');
|
||||||
expect(params['subtitleStreamID'], '401');
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||||
expect(params['advancedSubtitles'], 'text');
|
expect(params.containsKey('advancedSubtitles'), isFalse);
|
||||||
expect(params.containsKey('X-Plex-Chunked'), isFalse);
|
expect(params.containsKey('X-Plex-Chunked'), isFalse);
|
||||||
expect(params['X-Plex-Incomplete-Segments'], '1');
|
expect(params['X-Plex-Incomplete-Segments'], '1');
|
||||||
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
|
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
|
||||||
@@ -444,8 +518,7 @@ void main() {
|
|||||||
profile,
|
profile,
|
||||||
contains(
|
contains(
|
||||||
'add-transcode-target(type=videoProfile&context=streaming'
|
'add-transcode-target(type=videoProfile&context=streaming'
|
||||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
'&protocol=hls&container=mpegts',
|
||||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -495,43 +568,20 @@ void main() {
|
|||||||
expect(params['partIndex'], '2');
|
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));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
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, [
|
final subtitles = buildTranscodeSubtitles(client, [
|
||||||
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
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', () {
|
group('playback metadata failure contract', () {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
import 'package:plezy/media/media_item.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/media/media_source_info.dart';
|
||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/mpv/player/player_stream_controllers.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/settings_service.dart';
|
||||||
import 'package:plezy/services/track_manager.dart';
|
import 'package:plezy/services/track_manager.dart';
|
||||||
|
|
||||||
@@ -369,6 +372,361 @@ void main() {
|
|||||||
expect(player.selectedSubtitle, hasLength(1));
|
expect(player.selectedSubtitle, hasLength(1));
|
||||||
expect(player.selectedSubtitle.single.id, 'no');
|
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', () {
|
group('applyTrackSelection ownership', () {
|
||||||
|
|||||||
@@ -99,7 +99,18 @@ SubtitleTrack _sub(
|
|||||||
String? codec,
|
String? codec,
|
||||||
bool isDefault = false,
|
bool isDefault = false,
|
||||||
bool isForced = 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(
|
MediaAudioTrack _plexAudio(
|
||||||
int id, {
|
int id, {
|
||||||
@@ -132,6 +143,8 @@ MediaSubtitleTrack _plexSub(
|
|||||||
bool selected = false,
|
bool selected = false,
|
||||||
bool forced = false,
|
bool forced = false,
|
||||||
String? codec,
|
String? codec,
|
||||||
|
bool external = false,
|
||||||
|
String? key,
|
||||||
}) {
|
}) {
|
||||||
return MediaSubtitleTrack(
|
return MediaSubtitleTrack(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -142,6 +155,8 @@ MediaSubtitleTrack _plexSub(
|
|||||||
selected: selected,
|
selected: selected,
|
||||||
forced: forced,
|
forced: forced,
|
||||||
codec: codec,
|
codec: codec,
|
||||||
|
external: external,
|
||||||
|
key: key,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,14 +453,14 @@ void main() {
|
|||||||
group('selectSubtitleTrack', () {
|
group('selectSubtitleTrack', () {
|
||||||
test('Priority 1: preferred id="no" forces subtitles off', () {
|
test('Priority 1: preferred id="no" forces subtitles off', () {
|
||||||
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
|
||||||
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null);
|
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.navigation);
|
expect(result.priority, TrackSelectionPriority.navigation);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Priority 1: preferred subtitle from navigation matches by language', () {
|
test('Priority 1: preferred subtitle from navigation matches by language', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||||
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null);
|
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.navigation);
|
expect(result.priority, TrackSelectionPriority.navigation);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -458,11 +473,90 @@ void main() {
|
|||||||
_plexSub(11, language: 'fre', languageCode: 'fre', selected: true),
|
_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.priority, TrackSelectionPriority.serverSelected);
|
||||||
expect(result.track.language, 'fre');
|
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', () {
|
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||||
final info = _info(
|
final info = _info(
|
||||||
@@ -475,7 +569,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: info,
|
info: info,
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
expect(result.track.language, 'fre');
|
expect(result.track.language, 'fre');
|
||||||
});
|
});
|
||||||
@@ -489,7 +583,7 @@ void main() {
|
|||||||
_plexSub(11, language: 'fre'),
|
_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.priority, TrackSelectionPriority.serverSelected);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
@@ -505,7 +599,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: info,
|
info: info,
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
expect(result.priority, TrackSelectionPriority.defaultTrack);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -522,7 +616,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: info,
|
info: info,
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
expect(result.track.language, 'fre');
|
expect(result.track.language, 'fre');
|
||||||
});
|
});
|
||||||
@@ -539,7 +633,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
info: info,
|
info: info,
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.serverSelected);
|
expect(result.priority, TrackSelectionPriority.serverSelected);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
@@ -549,7 +643,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none),
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none),
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
@@ -563,7 +657,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -573,7 +667,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
@@ -583,7 +677,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -593,7 +687,7 @@ void main() {
|
|||||||
final result = _svc(
|
final result = _svc(
|
||||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||||
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
|
||||||
).selectSubtitleTrack(tracks, null, null);
|
).selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -607,7 +701,7 @@ void main() {
|
|||||||
defaultSubtitleLanguage: 'eng',
|
defaultSubtitleLanguage: 'eng',
|
||||||
subtitleMode: SubtitlePlaybackMode.smart,
|
subtitleMode: SubtitlePlaybackMode.smart,
|
||||||
),
|
),
|
||||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'));
|
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'))!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
@@ -621,30 +715,57 @@ void main() {
|
|||||||
defaultSubtitleLanguage: 'eng',
|
defaultSubtitleLanguage: 'eng',
|
||||||
subtitleMode: SubtitlePlaybackMode.smart,
|
subtitleMode: SubtitlePlaybackMode.smart,
|
||||||
),
|
),
|
||||||
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'));
|
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'))!;
|
||||||
expect(result.priority, TrackSelectionPriority.profile);
|
expect(result.priority, TrackSelectionPriority.profile);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Priority 3: default-flagged track when no Plex info', () {
|
test('Priority 3: default-flagged track when no Plex info', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)];
|
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.priority, TrackSelectionPriority.defaultTrack);
|
||||||
expect(result.track.id, '2');
|
expect(result.track.id, '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Priority 4: off when no default and no info', () {
|
test('Priority 4: off when no default and no info', () {
|
||||||
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
|
||||||
final result = _svc().selectSubtitleTrack(tracks, null, null);
|
final result = _svc().selectSubtitleTrack(tracks, null, null)!;
|
||||||
expect(result.priority, TrackSelectionPriority.off);
|
expect(result.priority, TrackSelectionPriority.off);
|
||||||
expect(result.track.id, 'no');
|
expect(result.track.id, 'no');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Priority 4: off when no available tracks at all', () {
|
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.priority, TrackSelectionPriority.off);
|
||||||
expect(result.track.id, 'no');
|
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', () {
|
group('findPlexTrackForMpvAudio - same-language disambiguation', () {
|
||||||
// Two French audio tracks differing only by channel count, titles null.
|
// Two French audio tracks differing only by channel count, titles null.
|
||||||
final plexTracks = [
|
final plexTracks = [
|
||||||
|
|||||||
Reference in New Issue
Block a user