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