fix(playback): prevent native subtitle autoselect

This commit is contained in:
edde746
2026-05-30 00:05:29 +02:00
parent a71711de5f
commit 1685ecc991
9 changed files with 175 additions and 11 deletions
@@ -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
View File
@@ -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');
+10 -1
View File
@@ -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(),
});
}
+5
View File
@@ -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,
),
);
}
+3
View File
@@ -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) {
+106
View File
@@ -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';
});
}