From e069d30555001cbc53b8fc75b8b3716d9068f5ec Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:28:54 +0200 Subject: [PATCH] fix(android): block direct audio without passthrough --- .../plezy/exoplayer/AudioOutputPolicy.kt | 19 ++++++++ .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 32 +++++++++++-- .../plezy/exoplayer/AudioOutputPolicyTest.kt | 47 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt 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 new file mode 100644 index 00000000..5f480ff6 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt @@ -0,0 +1,19 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.MimeTypes + +internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeType) { + "audio/ac3", + "audio/eac3", + "audio/eac3-joc", + "audio/ac4", + "audio/vnd.dts", + "audio/vnd.dts.hd", + "audio/vnd.dts.uhd", + MimeTypes.AUDIO_TRUEHD -> true + else -> false +} + +internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean { + return !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType) +} 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 5d7386e7..894cf096 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 @@ -188,10 +188,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Loudness normalization (#1289): audiofx effects only process non-tunneled // PCM mixer streams, so while enabled we block direct/bitstream output and // disable tunneling. + private var audioPassthroughEnabled: Boolean = false private var audioNormalizationEnabled: Boolean = false private val audioNormalization = AudioNormalizationEffect(::emitLog) private var pendingAudioRendererBounce: Boolean = false - private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") } + private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio renderer bounce timeout") } private var lastSeekable: Boolean? = null private var forceSeekable: Boolean = false @@ -370,13 +371,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return decision.mode } - fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean { + fun initialize( + bufferSizeBytes: Int? = null, + tunnelingEnabled: Boolean = true, + audioPassthroughEnabled: Boolean = false + ): Boolean { if (isInitialized) { Log.d(TAG, "Already initialized") return true } tunnelingUserEnabled = tunnelingEnabled + this.audioPassthroughEnabled = audioPassthroughEnabled this.dvMode = getConfiguredDvMode() DoviBridge.logSupportSummary(activity) Log.i( @@ -1840,6 +1846,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val mimeType = format.sampleMimeType ?: return false // Loudness normalization needs decoded PCM for the audiofx chain to act on. if (audioNormalizationEnabled && isEncodedAudioMimeType(mimeType)) return true + if (shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled)) return true if (directAudioOutputBlockedAfterFailure.contains(mimeType)) { if (loggedDirectAudioRecoveryBlocks.add("$mimeType|$reason")) { emitLog( @@ -2720,6 +2727,25 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } + fun setAudioPassthrough(enabled: Boolean) { + if (audioPassthroughEnabled == enabled) return + audioPassthroughEnabled = enabled + emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}") + + if (exoPlayer == null) return + val outputEncoding = lastAudioTrackConfig?.encoding + val selectedMime = selectedAudioFormat()?.sampleMimeType + val needsBounce = outputEncoding != null && + selectedMime != null && + isPassthroughAudioMimeType(selectedMime) && + (if (enabled) isPcmEncoding(outputEncoding) else !isPcmEncoding(outputEncoding)) + if (needsBounce) { + startAudioRendererBounce("audio-passthrough") + } else { + updateTunnelingState("audio-passthrough") + } + } + private fun attachNormalizationEffect() { val sessionId = exoPlayer?.audioSessionId ?: C.AUDIO_SESSION_ID_UNSET if (sessionId == C.AUDIO_SESSION_ID_UNSET) { @@ -2737,7 +2763,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun startAudioRendererBounce(reason: String) { if (pendingAudioRendererBounce) return pendingAudioRendererBounce = true - emitLog("info", "audio-normalization", "Bouncing audio renderer to re-evaluate output path (reason=$reason)") + emitLog("info", "audio", "Bouncing audio renderer to re-evaluate output path (reason=$reason)") applyTrackSelectorPolicy(reason = "$reason (audio renderer off)", audioDisabled = true) handler.postDelayed(audioBounceTimeout, AUDIO_BOUNCE_TIMEOUT_MS) } 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 new file mode 100644 index 00000000..156689d9 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt @@ -0,0 +1,47 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.MimeTypes +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioOutputPolicyTest { + + @Test + fun passthroughDisabledBlocksBitstreamFormats() { + val bitstreamFormats = listOf( + "audio/ac3", + "audio/eac3", + "audio/eac3-joc", + "audio/ac4", + "audio/vnd.dts", + "audio/vnd.dts.hd", + "audio/vnd.dts.uhd", + MimeTypes.AUDIO_TRUEHD + ) + + for (mimeType in bitstreamFormats) { + assertTrue(mimeType, shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled = false)) + } + } + + @Test + fun passthroughEnabledAllowsBitstreamFormats() { + assertFalse(shouldBlockDirectOutputForPassthrough("audio/ac3", audioPassthroughEnabled = true)) + assertFalse(shouldBlockDirectOutputForPassthrough(MimeTypes.AUDIO_TRUEHD, audioPassthroughEnabled = true)) + } + + @Test + fun passthroughDisabledLeavesDecodedFormatsAvailable() { + val decodedFormats = listOf( + MimeTypes.AUDIO_AAC, + MimeTypes.AUDIO_OPUS, + MimeTypes.AUDIO_RAW, + MimeTypes.AUDIO_FLAC + ) + + for (mimeType in decodedFormats) { + assertFalse(mimeType, shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled = false)) + } + } +}