From c5e7ce648b6cbf7c4feef40f0ca0f55b172c480e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:34:05 +0200 Subject: [PATCH] fix(player): gate audio passthrough on the IEC 61937 shapes each backend opens On Shield Experience 8.x (API 28) ExoPlayer force-decoded TrueHD to multi-channel PCM, and enabling passthrough on the mpv backend made every non-AAC file wedge on an infinite buffering spinner with no audio pipeline at all. Two defects behind one symptom pair: - The MAT/DTS-HD carrier oracle stopped at API 29 because AudioTrack.isDirectPlaybackSupported does not exist below it, so API 24-28 never offered the carrier even on routes that genuinely bitstream it. Below API 29 the HDMI AudioDeviceInfo is the only vouching signal, so the carrier is now offered when an HDMI output explicitly advertises IEC 61937 at 192kHz/8ch; unspecified (empty) capability arrays deliberately do not count, because an unvouched IEC track that initialises without being bitstreamed renders as full-scale noise. A route that advertises and still refuses the track fails AudioTrack init into the existing force-decode recovery. - The mpv audio-spdif list was gated on the raw encodings the route advertises, but mpv's audiotrack AO opens every spdif format as a stereo ENCODING_IEC61937 track clamped to the 48kHz mixer rate. E-AC3, TrueHD and DTS-HD MA structurally cannot survive that shape, and a route can advertise raw encodings while its HAL takes no IEC track at all - naming any codec there leaves mpv's audio chain stuck before AO init (reproduced on a Shield: spdif_ac3 selected, no AudioTrack ever opened, playback never starts). The list is now capped to ac3,dts and additionally gated on the route accepting the stereo IEC shape, using the same tiered oracle as the carrier. The primary mpv backend previously wrote a hardcoded five-codec list from Dart; it now asks the plugin to derive the value from the audio route, like the ExoPlayer fallback already did. Verified on a Shield (API 30) with a full-capability EDID: TrueHD rides the carrier and survives speed transitions (instrumentation), and an E-AC3 title that previously wedged under mpv+passthrough now decodes and plays with audio-spdif=ac3,dts. close #1991 --- .../exoplayer/TrueHdSpeedTransitionTest.kt | 2 +- .../plezy/exoplayer/AudioOutputPolicy.kt | 165 ++++++++++++------ .../plezy/exoplayer/PlezyRenderersFactory.kt | 2 +- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 12 ++ .../plezy/exoplayer/AudioOutputPolicyTest.kt | 98 ++++++----- .../edde746/plezy/mpv/MpvPlayerPluginTest.kt | 13 ++ lib/mpv/player/player_native.dart | 17 +- 7 files changed, 212 insertions(+), 97 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt index 4d1ec51a5..3a69aa7ed 100644 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt +++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt @@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest { @Test fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() { val context = InstrumentationRegistry.getInstrumentation().targetContext - if (!supportsIecCarrier()) { + if (!supportsIecCarrier(context)) { Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====") return } 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 2ec868a4e..a0678f121 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,7 @@ package com.edde746.plezy.exoplayer import android.content.Context +import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager import android.media.AudioTrack @@ -46,13 +47,17 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) { /** * mpv `audio-spdif` codec names and the exact platform encoding a route must * advertise to carry that bitstream. + * + * Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every + * spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to + * `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD + * (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one + * anyway force-passes the codec into a track that cannot carry it (#1991); those streams + * decode to PCM instead, like every other unnamed codec. */ 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 + "dts" to C.ENCODING_DTS ) /** @@ -61,23 +66,36 @@ private val MPV_SPDIF_CODECS: List> = listOf( * 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. + * The gate is the exact encoding rather than media3's passthrough probe on purpose. That + * probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects + * channel counts above the route's PCM maximum, neither of which describes what an IEC + * 61937 track carries. */ 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. */ +/** + * [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. + * + * Two conditions, both required: + * - The route must accept the exact track shape mpv opens for spdif output — stereo + * IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS + * encodings only says the receiver decodes them, not that the HAL takes an IEC 61937 + * AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv + * spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable. + * - The receiver must decode the codec itself — the raw encoding on the current + * [AudioCapabilities] — because IEC 61937 is transport, not transcoding. + */ // 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 { + if (!supportsMpvIecShape(context)) { + Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming") + return "" + } val audioAttributes = AudioAttributes.Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .setUsage(C.USAGE_MEDIA) @@ -91,6 +109,26 @@ internal fun supportedMpvSpdifCodecs(context: Context): String { return mpvSpdifCodecs(capabilities::supportsEncoding) } +/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */ +private const val MPV_IEC_SAMPLE_RATE = 48_000 +private const val MPV_IEC_CHANNEL_COUNT = 2 + +internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported( + sdkInt = Build.VERSION.SDK_INT, + canSizeBuffer = { canSizeIecBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) }, + // The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi + // check cannot see through the injected lambdas. + bitstreamSupported = { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + iecBitstreamSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO)) + }, + directPlaybackSupported = { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO)) + }, + hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) } +) + /** * Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT * (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple. @@ -101,8 +139,8 @@ internal fun supportedMpvSpdifCodecs(context: Context): String { * layer about the encoding, which on the boxes measured for this issue answers "TrueHD is * offload-capable" and says nothing about whether a raw TrueHD track will ever drain. * - * Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle - * proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is + * Both are consulted: `getMinBufferSize` proves a track can be built, and a route oracle proves + * the route will actually bitstream it rather than silently decode or wedge. Sizing alone is * not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to * initialise. * @@ -113,72 +151,99 @@ internal fun supportedMpvSpdifCodecs(context: Context): String { * for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way * and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails * AudioTrack initialisation, which the audio recovery path answers by force-decoding. - * - Below API 29 there is no oracle at all, so the carrier is not offered and the stream decodes - * as before. + * - API 24–28: no runtime oracle exists, so the HDMI `AudioDeviceInfo` must explicitly advertise + * IEC 61937 at 192kHz/8ch. Shield Experience 8.x is API 28, and the previous flat `false` on + * this tier force-decoded TrueHD on routes that genuinely carry it (#1991). A route that + * advertises and still refuses the track fails AudioTrack initialisation into the same + * recovery path as the tier above. */ -internal fun supportsIecCarrier(): Boolean = iecCarrierSupported( +internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported( sdkInt = Build.VERSION.SDK_INT, - canSizeCarrierBuffer = { - try { - AudioTrack.getMinBufferSize( - IecCarrier.SAMPLE_RATE, - AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, - AudioFormat.ENCODING_IEC61937 - ) > 0 - } catch (error: Exception) { - false - } - }, - // The SDK_INT guards repeat iecCarrierSupported's tiering only because lint's NewApi + canSizeBuffer = { canSizeIecBuffer(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) }, + // The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi // check cannot see through the injected lambdas. bitstreamSupported = { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported() + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + iecBitstreamSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)) }, directPlaybackSupported = { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported() - } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + iecDirectPlaybackSupported(iecProbeFormat(IecCarrier.SAMPLE_RATE, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)) + }, + hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) } ) /** - * [supportsIecCarrier] with the platform probes injected. Probes are only consulted on the - * API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and - * [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32. + * [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only + * consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) + * on 33+, [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32, and + * [hdmiRouteAdvertised] (explicit HDMI `AudioDeviceInfo` advertisement) on 24–28 (#1991). */ -internal fun iecCarrierSupported( +internal fun iecRouteSupported( sdkInt: Int, - canSizeCarrierBuffer: () -> Boolean, + canSizeBuffer: () -> Boolean, bitstreamSupported: () -> Boolean, - directPlaybackSupported: () -> Boolean + directPlaybackSupported: () -> Boolean, + hdmiRouteAdvertised: () -> Boolean ): Boolean = when { - sdkInt < Build.VERSION_CODES.Q -> false - !canSizeCarrierBuffer() -> false + // ENCODING_IEC61937 itself only exists from API 24. + sdkInt < Build.VERSION_CODES.N -> false + !canSizeBuffer() -> false sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported() - else -> directPlaybackSupported() + sdkInt >= Build.VERSION_CODES.Q -> directPlaybackSupported() + else -> hdmiRouteAdvertised() +} + +private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try { + AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0 +} catch (error: Exception) { + false } @RequiresApi(Build.VERSION_CODES.TIRAMISU) -private fun iecCarrierBitstreamSupported(): Boolean = try { - val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), movieAudioAttributes()) +private fun iecBitstreamSupported(format: AudioFormat): Boolean = try { + val support = AudioManager.getDirectPlaybackSupport(format, movieAudioAttributes()) (support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0 } catch (error: Exception) { - Log.w(TAG, "IEC 61937 carrier probe failed; not offering the bitstream carrier", error) + Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error) false } @RequiresApi(Build.VERSION_CODES.Q) @Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses. -private fun iecCarrierDirectPlaybackSupported(): Boolean = try { - AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes()) +private fun iecDirectPlaybackSupported(format: AudioFormat): Boolean = try { + AudioTrack.isDirectPlaybackSupported(format, movieAudioAttributes()) } catch (error: Exception) { - Log.w(TAG, "IEC 61937 carrier probe failed; not offering the bitstream carrier", error) + Log.w(TAG, "IEC 61937 route probe failed; not offering bitstream output", error) false } -/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */ -private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder() +/** + * Whether an HDMI output *explicitly* advertises IEC 61937 at [sampleRate]/[channelCount] — + * the only oracle below API 29 (#1991). + * + * Empty `AudioDeviceInfo` capability arrays mean "unspecified" and deliberately fail this + * check: an unvouched IEC track that initialises on a route that then renders it as PCM plays + * the carrier as full-scale noise, which the AudioTrack-init recovery path cannot catch. + */ +private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCount: Int): Boolean = try { + val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device -> + (device.type == AudioDeviceInfo.TYPE_HDMI || device.type == AudioDeviceInfo.TYPE_HDMI_ARC) && + device.encodings.contains(AudioFormat.ENCODING_IEC61937) && + device.sampleRates.contains(sampleRate) && + device.channelCounts.contains(channelCount) + } +} catch (error: Exception) { + Log.w(TAG, "HDMI route inspection failed; not offering IEC 61937 output", error) + false +} + +/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */ +private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_IEC61937) - .setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND) - .setSampleRate(IecCarrier.SAMPLE_RATE) + .setChannelMask(channelMask) + .setSampleRate(sampleRate) .build() private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index dd4c7f217..629c3a74d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -207,7 +207,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) return IecCarrierSink( defaultSink = processedSink, carrierSink = buildCarrierSink(context, bufferSizeProvider), - carrierRouteAvailable = { supportsIecCarrier() }, + carrierRouteAvailable = { supportsIecCarrier(context) }, directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true }, log = audioDiagnosticsLogger ).also { iecCarrierSink = it } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 5ee3e984a..b11941365 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -5,6 +5,7 @@ import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Log +import com.edde746.plezy.exoplayer.supportedMpvSpdifCodecs import com.edde746.plezy.shared.PlayerChannelBinding import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -159,6 +160,7 @@ open class MpvPlayerPlugin( "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) "clearVideoFrameRate" -> handleClearVideoFrameRate(result) "requestAudioFocus" -> handleRequestAudioFocus(result) + "getAudioSpdifCodecs" -> handleGetAudioSpdifCodecs(result) "abandonAudioFocus" -> handleAbandonAudioFocus(result) "openContentFd" -> handleOpenContentFd(call, result) "closeContentFd" -> handleCloseContentFd(call, result) @@ -168,6 +170,16 @@ open class MpvPlayerPlugin( } } + /** + * Derives the `audio-spdif` value for the current audio route. mpv force-passthroughs + * every codec named there with no decode fallback, so with no context to inspect the + * route the conservative answer is the empty list — decode everything (#1703, #1991). + */ + private fun handleGetAudioSpdifCodecs(result: MethodChannel.Result) { + val context: Context? = activity ?: applicationContext + result.success(context?.let(::supportedMpvSpdifCodecs) ?: "") + } + private fun handleInitialize(result: MethodChannel.Result) { // Video cores need the Activity (surface/view hierarchy); the audio-only // core is built on the application context so it can outlive it. 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 0ce2ed9eb..5fe5cc51b 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 @@ -48,34 +48,19 @@ class AudioOutputPolicyTest { } @Test - fun spdifListNamesEveryCodecAnUnrestrictedRouteAdvertises() { - assertEquals("ac3,eac3,dts,dts-hd,truehd", mpvSpdifCodecs { true }) + fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() { + // eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack + // AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate; + // naming them strands playback on a dead audio output (#1991). + assertEquals("ac3,dts", 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). + // Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does 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 }) + assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute }) } @Test @@ -84,47 +69,51 @@ class AudioOutputPolicyTest { } @Test - fun carrierIsNeverOfferedBelowApi29() { - // No direct-playback oracle exists there, and getMinBufferSize alone is known to lie - // (a Shield sizes the tuple, then the AudioTrack fails to initialise). + fun iecRouteIsNeverOfferedBelowApi24() { + // ENCODING_IEC61937 does not exist there. assertFalse( - iecCarrierSupported( - sdkInt = 28, - canSizeCarrierBuffer = { true }, + iecRouteSupported( + sdkInt = 23, + canSizeBuffer = { true }, bitstreamSupported = { true }, - directPlaybackSupported = { true } + directPlaybackSupported = { true }, + hdmiRouteAdvertised = { true } ) ) } @Test - fun carrierRequiresASizableBufferOnEveryTier() { - for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) { + fun iecRouteRequiresASizableBufferOnEveryTier() { + for (sdkInt in intArrayOf(25, 28, 29, 30, 32, 33, 34)) { assertFalse( "api $sdkInt", - iecCarrierSupported( + iecRouteSupported( sdkInt = sdkInt, - canSizeCarrierBuffer = { false }, + canSizeBuffer = { false }, bitstreamSupported = { true }, - directPlaybackSupported = { true } + directPlaybackSupported = { true }, + hdmiRouteAdvertised = { true } ) ) } } @Test - fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() { - // Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863). + fun routeOnApi24To28FollowsTheHdmiAdvertisement() { + // No runtime oracle exists below API 29; only an HDMI AudioDeviceInfo that explicitly + // advertises the IEC tuple can vouch for it. Shield Experience 8.x is API 28, and a flat + // `false` on this tier force-decoded TrueHD on routes that genuinely carry it (#1991). for (supported in booleanArrayOf(true, false)) { - for (sdkInt in intArrayOf(29, 30, 32)) { + for (sdkInt in intArrayOf(25, 28)) { assertEquals( "api $sdkInt supported=$supported", supported, - iecCarrierSupported( + iecRouteSupported( sdkInt = sdkInt, - canSizeCarrierBuffer = { true }, + canSizeBuffer = { true }, bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") }, - directPlaybackSupported = { supported } + directPlaybackSupported = { throw AssertionError("isDirectPlaybackSupported does not exist below API 29") }, + hdmiRouteAdvertised = { supported } ) ) } @@ -132,18 +121,39 @@ class AudioOutputPolicyTest { } @Test - fun carrierOnApi33UsesTheBitstreamProbe() { + fun routeOnApi29To32FollowsTheDirectPlaybackProbe() { + // Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863). + for (supported in booleanArrayOf(true, false)) { + for (sdkInt in intArrayOf(29, 30, 32)) { + assertEquals( + "api $sdkInt supported=$supported", + supported, + iecRouteSupported( + sdkInt = sdkInt, + canSizeBuffer = { true }, + bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") }, + directPlaybackSupported = { supported }, + hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") } + ) + ) + } + } + } + + @Test + fun routeOnApi33UsesTheBitstreamProbe() { // getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29 // probe must not shadow it where the platform can answer precisely. for (supported in booleanArrayOf(true, false)) { assertEquals( "supported=$supported", supported, - iecCarrierSupported( + iecRouteSupported( sdkInt = 33, - canSizeCarrierBuffer = { true }, + canSizeBuffer = { true }, bitstreamSupported = { supported }, - directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") } + directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }, + hdmiRouteAdvertised = { throw AssertionError("the HDMI advertisement must not shadow the runtime probe") } ) ) } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt index b5568bdbe..a648be857 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt @@ -48,6 +48,19 @@ class MpvPlayerPluginTest { assertNull(result.successValue) } + @Test + fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() { + // mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so + // with no context to inspect the audio route the only safe answer is "" (#1703, #1991). + val result = RecordingResult() + + MpvPlayerPlugin().onMethodCall(MethodCall("getAudioSpdifCodecs", null), result) + + assertEquals("", result.successValue) + assertNull(result.errorCode) + assertEquals(1, result.completionCount) + } + @Test fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() { for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index b186c9b53..661d4fce5 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -875,8 +875,23 @@ class PlayerNative extends PlayerBase { /// audio goes through the system renderer, which only handles Dolby /// Digital (Plus); Windows and Linux do real device passthrough for the /// full list. Never applied on macOS (PlatformDetector.supportsAudioPassthrough). + /// + /// Android does not use this list: mpv's audiotrack AO only opens stereo + /// IEC 61937 tracks at the 48kHz mixer rate, so naming a codec the route + /// cannot carry that way strands playback on a dead audio output (#1991). + /// The plugin derives the value from the current audio route instead. static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd'; + Future _resolvePassthroughCodecs() async { + if (!Platform.isAndroid) return _passthroughCodecs; + try { + return await invoke('getAudioSpdifCodecs') ?? ''; + } catch (error, stackTrace) { + appLogger.w('MPV: audio route inspection failed; decoding instead', error: error, stackTrace: stackTrace); + return ''; + } + } + _AudioStateRequest get _requestedAudioState => ( passthrough: _passthroughRequested, normalization: _normalizationRequested, @@ -1011,7 +1026,7 @@ class PlayerNative extends PlayerBase { } Future _applyPassthrough(bool enabled) async { - await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); + await setProperty('audio-spdif', enabled ? await _resolvePassthroughCodecs() : ''); if (_nativeCoreUnavailable) return; // audio-spdif is the authoritative transition. Publish only after mpv