fix(android): derive the mpv fallback passthrough list from the audio route

Audio passthrough defaults on for Android TV, scoped to ExoPlayer because
mpv force-passes through every codec named in audio-spdif and has no
decode fallback. That scoping did not survive the ExoPlayer to mpv
handoff: PlayerAndroid queued the raw ac3,eac3,dts,dts-hd,truehd list as
a pending mpv property and prepareMpvFallback replayed it verbatim, so a
sink that bitstreams only Dolby formats was told to force TrueHD and
DTS-HD anyway. mpv selected spdif_truehd, the audio output never
initialised, and playback froze at its start position while still showing
a first frame — the stop timeline reported the position it opened with.

Treat passthrough as a request and resolve the codec list against the
route when mpv actually starts, so an HDMI or AVR change between
ExoPlayer startup and the handoff cannot replay codecs from the old sink.
Gate each codec on the exact advertised encoding rather than media3's
passthrough probe: that probe answers DTS-HD by downgrading to the DTS
core, and mpv reads "dts,dts-hd" as "dts-hd" alone, so accepting the
downgrade would name DTS-HD MA to a core-only receiver and lose DTS too.
This commit is contained in:
edde746
2026-07-28 19:44:16 +02:00
parent ae331b217c
commit a183c17c3b
6 changed files with 198 additions and 9 deletions
@@ -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<Pair<String, Int>> = 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)
}
@@ -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<String, String>()
// 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<Map<String, Any?>>? = 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<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("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)
}
@@ -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 })
}
}
@@ -72,6 +72,22 @@ class ExoPlayerPluginTest {
}
}
@Test
fun fallbackPassthroughOnlyForcesCodecsTheRouteCanBitstream() {
val writes = mutableListOf<Pair<String, String>>()
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<Pair<String, String>>()
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<String, String>
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<String, Any?>(), "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(