fix(playback): prevent native subtitle autoselect
This commit is contained in:
@@ -440,7 +440,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
// encoded audio track when capabilities come back. See androidx/media#2258.
|
||||
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.setPreferredTextLanguage("en")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2279,7 +2278,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
startPositionMs: Long,
|
||||
autoPlay: Boolean,
|
||||
isLive: Boolean = false,
|
||||
externalSubtitleList: List<Map<String, String?>>? = null
|
||||
externalSubtitleList: List<Map<String, Any?>>? = null
|
||||
) {
|
||||
if (!isInitialized) return
|
||||
|
||||
@@ -2330,12 +2329,22 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
// Build external subtitle configurations (attached to MediaItem before prepare)
|
||||
externalSubtitleList?.forEachIndexed { index, sub ->
|
||||
val subUri = sub["uri"] ?: return@forEachIndexed
|
||||
val subUri = sub["uri"] as? String ?: return@forEachIndexed
|
||||
val title = sub["title"] as? String
|
||||
val language = sub["language"] as? String
|
||||
val codec = sub["codec"] as? String
|
||||
val mimeType = sub["mimeType"] as? String
|
||||
val isDefault = sub["isDefault"] as? Boolean ?: false
|
||||
val isForced = sub["isForced"] as? Boolean ?: false
|
||||
val selectionFlags =
|
||||
(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
|
||||
(if (isForced) C.SELECTION_FLAG_FORCED else 0)
|
||||
val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
|
||||
.setId("external_$index")
|
||||
.setLabel(sub["title"] ?: "External")
|
||||
.setLanguage(sub["language"])
|
||||
.setMimeType(sub["mimeType"] ?: detectSubtitleMimeType(subUri))
|
||||
.setLabel(title ?: "External")
|
||||
.setLanguage(language)
|
||||
.setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
|
||||
.setSelectionFlags(selectionFlags)
|
||||
.build()
|
||||
externalSubtitles.add(config)
|
||||
externalSubtitleUris.add(subUri)
|
||||
@@ -2351,7 +2360,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
reason = "open",
|
||||
forceSelector = true,
|
||||
clearAudioOverrides = true,
|
||||
clearTextOverrides = true
|
||||
clearTextOverrides = true,
|
||||
textDisabled = true
|
||||
)
|
||||
emitSeekable(false, force = true)
|
||||
|
||||
@@ -2618,6 +2628,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private fun subtitleMimeTypeForCodec(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"webvtt", "vtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun setVisible(visible: Boolean) {
|
||||
if (disposing) return
|
||||
currentVisible = visible
|
||||
|
||||
@@ -240,7 +240,7 @@ class ExoPlayerPlugin :
|
||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
|
||||
val isLive = call.argument<Boolean>("isLive") ?: false
|
||||
val externalSubtitles = call.argument<List<Map<String, String?>>>("externalSubtitles")
|
||||
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
|
||||
|
||||
if (uri == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
@@ -260,6 +260,8 @@ class ExoPlayerPlugin :
|
||||
val options = mutableListOf<String>()
|
||||
options.add("start=$startSeconds")
|
||||
if (!autoPlay) options.add("pause=yes")
|
||||
options.add("sid=no")
|
||||
options.add("secondary-sid=no")
|
||||
headers?.forEach { (key, value) ->
|
||||
options.add("http-header-fields-append=$key: $value")
|
||||
}
|
||||
|
||||
+17
-2
@@ -70,8 +70,23 @@ sealed class SubtitleTrack with _$SubtitleTrack {
|
||||
String? uri,
|
||||
}) = _SubtitleTrack;
|
||||
|
||||
factory SubtitleTrack.uri(String uri, {String? title, String? language}) =>
|
||||
SubtitleTrack(id: 'external:$uri', title: title, language: language, isExternal: true, uri: uri);
|
||||
factory SubtitleTrack.uri(
|
||||
String uri, {
|
||||
String? title,
|
||||
String? language,
|
||||
String? codec,
|
||||
bool isDefault = false,
|
||||
bool isForced = false,
|
||||
}) => SubtitleTrack(
|
||||
id: 'external:$uri',
|
||||
title: title,
|
||||
language: language,
|
||||
codec: codec,
|
||||
isDefault: isDefault,
|
||||
isForced: isForced,
|
||||
isExternal: true,
|
||||
uri: uri,
|
||||
);
|
||||
|
||||
static const auto = SubtitleTrack(id: 'auto', title: 'Auto');
|
||||
|
||||
|
||||
@@ -120,7 +120,16 @@ class PlayerAndroid extends PlayerBase {
|
||||
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
|
||||
'externalSubtitles': externalSubtitles
|
||||
.where((s) => s.uri != null)
|
||||
.map((s) => {'uri': s.uri, 'title': s.title, 'language': s.language})
|
||||
.map(
|
||||
(s) => {
|
||||
'uri': s.uri,
|
||||
'title': s.title,
|
||||
'language': s.language,
|
||||
'codec': s.codec,
|
||||
'isDefault': s.isDefault,
|
||||
'isForced': s.isForced,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ class PlayerNative extends PlayerBase {
|
||||
await setProperty('pause', 'yes');
|
||||
}
|
||||
|
||||
// Prevent mpv's own default subtitle selection from racing the
|
||||
// server-backed TrackManager decision applied after tracks are discovered.
|
||||
await setProperty('sid', 'no');
|
||||
await setProperty('secondary-sid', 'no');
|
||||
|
||||
// Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card downloads)
|
||||
var uri = media.uri;
|
||||
if (Platform.isAndroid && uri.startsWith('content://')) {
|
||||
|
||||
@@ -343,6 +343,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
|
||||
cleanTrackMetadataValue(track.language),
|
||||
language: cleanTrackMetadataValue(track.languageCode),
|
||||
codec: track.codec,
|
||||
isDefault: track.selected,
|
||||
isForced: track.forced,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,6 +270,9 @@ class PlaybackInitializationService {
|
||||
'file://${entity.path}',
|
||||
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName',
|
||||
language: cachedTrack?.languageCode,
|
||||
codec: cachedTrack?.codec,
|
||||
isDefault: cachedTrack?.selected ?? false,
|
||||
isForced: cachedTrack?.forced ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3437,6 +3437,9 @@ class PlexClient
|
||||
url,
|
||||
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
codec: plexTrack.codec,
|
||||
isDefault: plexTrack.selected,
|
||||
isForced: plexTrack.forced,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -34,6 +34,58 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('ExoPlayer forwards external subtitle metadata at open', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await _withMockChannels(
|
||||
methodChannelName: 'com.plezy/exo_player',
|
||||
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);
|
||||
}
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerAndroid();
|
||||
try {
|
||||
await player.open(
|
||||
Media('https://example.test/movie.mkv'),
|
||||
externalSubtitles: const [
|
||||
SubtitleTrack(
|
||||
id: 'external-sub',
|
||||
title: 'English Forced',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isDefault: true,
|
||||
isForced: true,
|
||||
isExternal: true,
|
||||
uri: 'https://example.test/sub.srt',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('ExoPlayer applies DV conversion mode changed during in-flight initialization', () async {
|
||||
final initialize = Completer<bool>();
|
||||
final calls = <MethodCall>[];
|
||||
@@ -105,6 +157,44 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV disables subtitles before loading media', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await _withMockChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'initialize':
|
||||
return Future.value(true);
|
||||
default:
|
||||
return Future.value(null);
|
||||
}
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
await player.open(Media('https://example.test/next.mkv'));
|
||||
|
||||
final sidIndex = _setPropertyCallIndex(calls, 'sid');
|
||||
final secondarySidIndex = _setPropertyCallIndex(calls, 'secondary-sid');
|
||||
final loadIndex = _loadfileCallIndex(calls);
|
||||
|
||||
expect(sidIndex, greaterThanOrEqualTo(0));
|
||||
expect(secondarySidIndex, greaterThanOrEqualTo(0));
|
||||
expect(loadIndex, greaterThanOrEqualTo(0));
|
||||
expect(sidIndex, lessThan(loadIndex));
|
||||
expect(secondarySidIndex, lessThan(loadIndex));
|
||||
expect(_setPropertyValue(calls[sidIndex]), 'no');
|
||||
expect(_setPropertyValue(calls[secondarySidIndex]), 'no');
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV maps server-offset streams to absolute timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
@@ -235,3 +325,19 @@ void _seedTracks(dynamic player) {
|
||||
{'type': 'sub', 'id': '3_0', 'title': 'English', 'lang': 'eng', 'selected': true},
|
||||
]);
|
||||
}
|
||||
|
||||
int _setPropertyCallIndex(List<MethodCall> calls, String name) {
|
||||
return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name);
|
||||
}
|
||||
|
||||
String? _setPropertyName(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['name'] as String?;
|
||||
|
||||
String? _setPropertyValue(MethodCall call) => Map<Object?, Object?>.from(call.arguments as Map)['value'] as String?;
|
||||
|
||||
int _loadfileCallIndex(List<MethodCall> calls) {
|
||||
return calls.indexWhere((call) {
|
||||
if (call.method != 'command') return false;
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map)['args'] as List;
|
||||
return args.isNotEmpty && args.first == 'loadfile';
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user