diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 73f6b9ca..878899c3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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>? = null + externalSubtitleList: List>? = 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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 55ad5649..59ec90c6 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -240,7 +240,7 @@ class ExoPlayerPlugin : val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L val autoPlay = call.argument("autoPlay") ?: true val isLive = call.argument("isLive") ?: false - val externalSubtitles = call.argument>>("externalSubtitles") + val externalSubtitles = call.argument>>("externalSubtitles") if (uri == null) { result.error("INVALID_ARGS", "Missing 'uri'", null) @@ -260,6 +260,8 @@ class ExoPlayerPlugin : val options = mutableListOf() 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") } diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index d692ab3a..5e940e80 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -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'); diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index df5ae565..c61be47c 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -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(), }); } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index e8c9c2ae..ee1b5e7f 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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://')) { diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 82fb4db6..eb6f659f 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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, ), ); } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 45cccb8f..f005592f 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -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, ), ); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a313bcb1..46aa6a76 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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) { diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 2b7f55b9..f1554985 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -34,6 +34,58 @@ void main() { ); }); + test('ExoPlayer forwards external subtitle metadata at open', () async { + final calls = []; + + 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.from(openCall.arguments as Map); + final external = args['externalSubtitles'] as List; + final subtitle = Map.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(); final calls = []; @@ -105,6 +157,44 @@ void main() { ); }); + test('MPV disables subtitles before loading media', () async { + final calls = []; + + 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 = []; @@ -235,3 +325,19 @@ void _seedTracks(dynamic player) { {'type': 'sub', 'id': '3_0', 'title': 'English', 'lang': 'eng', 'selected': true}, ]); } + +int _setPropertyCallIndex(List calls, String name) { + return calls.indexWhere((call) => call.method == 'setProperty' && _setPropertyName(call) == name); +} + +String? _setPropertyName(MethodCall call) => Map.from(call.arguments as Map)['name'] as String?; + +String? _setPropertyValue(MethodCall call) => Map.from(call.arguments as Map)['value'] as String?; + +int _loadfileCallIndex(List calls) { + return calls.indexWhere((call) { + if (call.method != 'command') return false; + final args = Map.from(call.arguments as Map)['args'] as List; + return args.isNotEmpty && args.first == 'loadfile'; + }); +}