fix(player): preserve transcoded subtitles at high speed

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