diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt index 799aed75..2154ac7b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt @@ -1,6 +1,15 @@ package com.edde746.plezy.exoplayer +import android.content.Context +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.AudioCapabilities + +private const val TAG = "AudioOutputPolicy" internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeType) { "audio/ac3", @@ -15,3 +24,51 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT } internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType) + +/** + * mpv `audio-spdif` codec names and the exact platform encoding a route must + * advertise to carry that bitstream. + */ +private val MPV_SPDIF_CODECS: List> = listOf( + "ac3" to C.ENCODING_AC3, + "eac3" to C.ENCODING_E_AC3, + "dts" to C.ENCODING_DTS, + "dts-hd" to C.ENCODING_DTS_HD, + "truehd" to C.ENCODING_DOLBY_TRUEHD +) + +/** + * Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises. + * + * mpv force-passes through every codec named here and has no decode fallback, so an + * unsupported name leaves the file rendering video against a dead audio output (#1703). + * + * The gate is the exact encoding rather than media3's passthrough probe on purpose. + * That probe answers DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3) + * for receivers that decode only the base layer, and it also rejects channel counts + * above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv + * additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would + * name DTS-HD MA to a core-only receiver and lose DTS as well. + */ +internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS + .filter { (_, encoding) -> supportsEncoding(encoding) } + .joinToString(",") { (codec, _) -> codec } + +/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */ +// Deprecated only in favour of an overload that also takes spatializer channel masks, which +// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses. +@Suppress("DEPRECATION") +@OptIn(UnstableApi::class) +internal fun supportedMpvSpdifCodecs(context: Context): String { + val audioAttributes = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build() + val capabilities = try { + AudioCapabilities.getCapabilities(context, audioAttributes, null) + } catch (error: Exception) { + Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error) + return "" + } + return mpvSpdifCodecs(capabilities::supportsEncoding) +} 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 3f47ceb6..581dff76 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 @@ -127,6 +127,11 @@ class ExoPlayerPlugin : // only at real session boundaries so settings can be replayed if a // superseded load requires a fresh MPV core. private val pendingMpvProperties = LinkedHashMap() + + // Audio passthrough is a request, not a queued mpv property: mpv force-passthroughs + // every codec in audio-spdif with no decode fallback, so the fallback core's value is + // derived from the audio route at the moment mpv actually starts (#1703). + private var audioPassthroughRequested = false private var currentExternalSubtitles: List>? = null // FlutterPlugin @@ -161,6 +166,7 @@ class ExoPlayerPlugin : inFlightOpen = null currentExternalSubtitles = null pendingMpvProperties.clear() + audioPassthroughRequested = false if (clearActivity) { activity = null activityBinding = null @@ -285,6 +291,9 @@ class ExoPlayerPlugin : val assVideoLatencyFrames = call.argument("assVideoLatencyFrames") ?: 0 val subtitleRenderScale = call.argument("subtitleRenderScale")?.toFloat() ?: 1.0f configuredBufferSizeBytes = bufferSizeBytes + // Seed the request here rather than waiting for Dart's separate setAudioPassthrough + // call, so a fallback raised before that arrives still derives audio-spdif correctly. + audioPassthroughRequested = audioPassthroughEnabled // Global libass overlay render scale — set before the player/handler is built below so the // first frame-size apply already uses it. AssHandler.setRenderScale(subtitleRenderScale) @@ -1134,13 +1143,14 @@ class ExoPlayerPlugin : result.error("INVALID_ARGS", "Missing 'enabled'", null) return } - val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else "" - pendingMpvProperties["audio-spdif"] = audioSpdif + audioPassthroughRequested = enabled + val currentActivity = activity if (usingMpvFallback) { + val audioSpdif = if (enabled && currentActivity != null) supportedMpvSpdifCodecs(currentActivity) else "" handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true) return } - activity?.runOnUiThread { + currentActivity?.runOnUiThread { playerCore?.setAudioPassthrough(enabled) result.success(true) } ?: result.error("NO_ACTIVITY", "Activity not available", null) @@ -1262,7 +1272,7 @@ class ExoPlayerPlugin : * completion callback on the main thread. */ private fun prepareMpvFallback(core: MpvPlayerCore) { - val pendingProps = pendingMpvProperties.toList() + val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList() val observedProps = observedProperties.toList() val bufferSize = configuredBufferSizeBytes @@ -1282,6 +1292,16 @@ class ExoPlayerPlugin : } } + // Derived last so it wins over any replayed value, and resolved here rather than + // when the setting was applied: the HDMI/AVR route can change between ExoPlayer + // startup and the moment mpv takes over (#1703). + val audioSpdif = activity + ?.takeIf { audioPassthroughRequested } + ?.let(::supportedMpvSpdifCodecs) + .orEmpty() + pendingMpvProperties["audio-spdif"] = audioSpdif + core.setProperty("audio-spdif", audioSpdif) + for ((propName, observed) in observedProps) { core.observeProperty(propName, observed.format) } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt index 156689d9..59a9f396 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt @@ -1,6 +1,8 @@ package com.edde746.plezy.exoplayer +import androidx.media3.common.C import androidx.media3.common.MimeTypes +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -44,4 +46,40 @@ class AudioOutputPolicyTest { assertFalse(mimeType, shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled = false)) } } + + @Test + fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() { + assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true }) + } + + @Test + fun spdifListDropsCodecsTheRouteCannotBitstream() { + // Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, + // TrueHD and DTS do not (#1703). + val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3) + + assertEquals("ac3,eac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute }) + } + + @Test + fun spdifListOmitsDtsHdOnDtsCoreOnlyRoutes() { + // media3's passthrough probe would answer yes for DTS-HD here by downgrading to the + // DTS core. mpv reads `dts,dts-hd` as `dts-hd` alone and force-passes DTS-HD MA, so + // accepting that downgrade would break DTS too. + val dtsCoreOnlyRoute = setOf(C.ENCODING_DTS) + + assertEquals("dts", mpvSpdifCodecs { encoding -> encoding in dtsCoreOnlyRoute }) + } + + @Test + fun spdifListKeepsDtsHdWhenTheRouteAdvertisesIt() { + val dtsHdRoute = setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD) + + assertEquals("dts,dts-hd", mpvSpdifCodecs { encoding -> encoding in dtsHdRoute }) + } + + @Test + fun spdifListIsEmptyForPcmOnlyRoutes() { + assertEquals("", mpvSpdifCodecs { false }) + } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt index 0c2347eb..5306d893 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt @@ -72,6 +72,22 @@ class ExoPlayerPluginTest { } } + @Test + fun fallbackPassthroughOnlyForcesCodecsTheRouteCanBitstream() { + val writes = mutableListOf>() + val plugin = fallbackPlugin { name, value -> writes += name to value } + val result = RecordingResult() + + plugin.onMethodCall(MethodCall("setAudioPassthrough", mapOf("enabled" to true)), result) + awaitCompletion(result) + + // mpv force-passthroughs every codec named in audio-spdif and has no decode + // fallback, so a route that bitstreams nothing must be told to force nothing — + // otherwise the fallback renders video against a dead audio output (#1703). + assertEquals(listOf("audio-spdif" to ""), writes) + assertEquals(true, result.successValue) + } + @Test fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() { for (case in fallbackPropertyCases()) { @@ -601,6 +617,28 @@ class ExoPlayerPluginTest { core.dispose() } + @Test + fun fallbackPrepareDerivesPassthroughFromTheRouteInsteadOfReplayingQueuedCodecs() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val writes = ConcurrentLinkedQueue>() + val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value } + val plugin = initialFallbackPlugin(activity, core) + setField(plugin, "audioPassthroughRequested", true) + @Suppress("UNCHECKED_CAST") + val pending = getField(plugin, "pendingMpvProperties") as MutableMap + pending["audio-spdif"] = "ac3,eac3,dts,dts-hd,truehd" + + invokeSetupMpvFallback(plugin, core, playWhenReady = true) + + // The queued ExoPlayer-era list is never replayed. This emulated route bitstreams + // nothing, and mpv has no decode fallback for a codec it force-passes through, so + // replaying it would strand playback on a dead audio output (#1703). + assertTrue(awaitQueueEntry(writes, "audio-spdif" to "")) + assertFalse(writes.contains("audio-spdif" to "ac3,eac3,dts,dts-hd,truehd")) + assertEquals("", pending["audio-spdif"]) + core.dispose() + } + @Test fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() @@ -814,8 +852,8 @@ class ExoPlayerPluginTest { FallbackPropertyCase("selectSubtitleTrack", emptyMap(), "sid" to "no"), FallbackPropertyCase( "setAudioPassthrough", - mapOf("enabled" to true), - "audio-spdif" to "ac3,eac3,dts,dts-hd,truehd", + mapOf("enabled" to false), + "audio-spdif" to "", true ), FallbackPropertyCase( diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index ebd9fd50..c00cd72f 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -20,8 +20,6 @@ class PlayerAndroid extends PlayerBase { int _downmixCenterBoostDb = 0; bool _downmixNormalize = true; - static const String _passthroughCodecs = 'ac3,eac3,dts,dts-hd,truehd'; - /// The native plugin switched from ExoPlayer to its mpv fallback for this /// session. Sticky for the instance lifetime, mirroring the native flag /// (which resets only on initialize/dispose). @@ -358,7 +356,11 @@ class PlayerAndroid extends PlayerBase { () => invoke('setAudioPassthrough', {'enabled': enabled}), () => _audioPassthroughEnabled == enabled, ); - await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); + // Deliberately no 'audio-spdif' write: unlike normalization and downmix, the + // mpv value is not this list. mpv force-passthroughs every codec named there + // with no decode fallback, so the plugin derives it from the audio route when + // the fallback core starts. Queuing the raw list here would overwrite it and + // strand TrueHD/DTS-HD on sinks that cannot bitstream them (#1703). } @override diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 5a151f74..751da0b3 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -192,6 +192,40 @@ void main() { ); }); + test('ExoPlayer leaves the mpv passthrough codec list to the native fallback', () async { + final calls = []; + await withMockPlayerChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) async { + calls.add(call); + if (call.method == 'initialize') return true; + if (call.method == 'requestAudioFocus') return true; + return null; + }, + testBody: () async { + final player = PlayerAndroid(); + try { + expect(await player.requestAudioFocus(), isTrue); + await player.setAudioPassthrough(true); + + final passthrough = calls.singleWhere((call) => call.method == 'setAudioPassthrough'); + expect((passthrough.arguments as Map)['enabled'], isTrue); + // mpv force-passthroughs audio-spdif with no decode fallback, so the + // plugin derives it from the audio route instead (#1703). + expect( + calls.where( + (call) => call.method == 'setMpvProperty' && (call.arguments as Map)['name'] == 'audio-spdif', + ), + isEmpty, + ); + } finally { + await player.dispose(); + } + }, + ); + }); + test('ExoPlayer retries initialization after a recoverable native failure', () async { var initializeAttempts = 0; await withMockPlayerChannels(