diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicy.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicy.kt index 434dc9f1..6f848eaa 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicy.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicy.kt @@ -2,6 +2,7 @@ package com.lostf1sh.pixelplayeross.data.service.player import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi +import com.lostf1sh.pixelplayeross.data.model.AudioOutputMode import java.util.Locale @androidx.annotation.OptIn(UnstableApi::class) @@ -13,12 +14,21 @@ internal object AudioDecoderPolicy { AUDIO_MIDI ) - fun shouldUseExtensionRenderer(mimeType: String): Boolean { - return extensionOnlyMimeTypes.any { it.equals(mimeType, ignoreCase = true) } + fun shouldUseExtensionRenderer(mimeType: String, outputMode: AudioOutputMode): Boolean { + // Some platform FLAC decoders produce corrupt audio when Media3 requests PCM_FLOAT + // (reported on the Galaxy S25 Ultra, issue #122). FFmpeg can decode FLAC directly to + // float PCM without that platform negotiation. Keep the normal decoder order in the + // integer output modes, where the reporter confirmed playback works. + return (outputMode.usesFloatOutput && MimeTypes.AUDIO_FLAC.equals(mimeType, ignoreCase = true)) || + extensionOnlyMimeTypes.any { it.equals(mimeType, ignoreCase = true) } } - fun selectPlatformDecoders(mimeType: String, decoderInfos: List): List { - return if (shouldUseExtensionRenderer(mimeType)) { + fun selectPlatformDecoders( + mimeType: String, + decoderInfos: List, + outputMode: AudioOutputMode + ): List { + return if (shouldUseExtensionRenderer(mimeType, outputMode)) { emptyList() } else { decoderInfos diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt index b83ac614..64287671 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt @@ -990,6 +990,9 @@ class DualPlayerEngine @Inject constructor( } private fun buildPlayer(): ExoPlayer { + // Decoder selection happens on the playback thread. Keep it tied to this player's sink + // even while a mode change is rebuilding the players. + val outputMode = audioOutputMode val mediaCodecSelector = MediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> val decoderInfos = MediaCodecSelector.DEFAULT.getDecoderInfos( mimeType, @@ -997,7 +1000,7 @@ class DualPlayerEngine @Inject constructor( requiresTunnelingDecoder ) - AudioDecoderPolicy.selectPlatformDecoders(mimeType, decoderInfos) + AudioDecoderPolicy.selectPlatformDecoders(mimeType, decoderInfos, outputMode) } val renderersFactory = object : DefaultRenderersFactory(context) { override fun buildAudioSink( @@ -1005,7 +1008,7 @@ class DualPlayerEngine @Inject constructor( enableFloatOutput: Boolean, enableAudioOutputPlaybackParams: Boolean ): AudioSink { - if (audioOutputMode.usesUnmodifiedMedia3AudioSink) { + if (outputMode.usesUnmodifiedMedia3AudioSink) { // Android's audio policy may // grant a DIRECT thread for a compatible device/format, or safely fall back // to the mixed path. This deliberately does not promise exclusive output. @@ -1018,7 +1021,7 @@ class DualPlayerEngine @Inject constructor( ) { "Media3 did not create its default AudioSink" } } return DefaultAudioSink.Builder(context) - .setEnableFloatOutput(audioOutputMode.usesFloatOutput) + .setEnableFloatOutput(outputMode.usesFloatOutput) .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) .setAudioProcessorChain( DefaultAudioSink.DefaultAudioProcessorChain( @@ -1056,7 +1059,7 @@ class DualPlayerEngine @Inject constructor( out: ArrayList ) { } - }.setEnableAudioFloatOutput(audioOutputMode.usesFloatOutput) + }.setEnableAudioFloatOutput(outputMode.usesFloatOutput) .setMediaCodecSelector(mediaCodecSelector) .setEnableDecoderFallback(true) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) @@ -1126,7 +1129,7 @@ class DualPlayerEngine @Inject constructor( .setMediaSourceFactory(DefaultMediaSourceFactory(resolvingFactory, extractorsFactory)) .setLoadControl(loadControl) .build().apply { - if (!audioOutputMode.usesUnmodifiedMedia3AudioSink) { + if (!outputMode.usesUnmodifiedMedia3AudioSink) { sharedAudioSessionIdOrNull()?.let { setAudioSessionId(it) } } setAudioAttributes(audioAttributes, false) diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicyTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicyTest.kt index b77caf4b..07e0644b 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicyTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/AudioDecoderPolicyTest.kt @@ -1,38 +1,74 @@ package com.lostf1sh.pixelplayeross.data.service.player import androidx.media3.common.MimeTypes +import com.lostf1sh.pixelplayeross.data.model.AudioOutputMode import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource class AudioDecoderPolicyTest { - @Test - fun selectPlatformDecoders_routesAlacToExtensionRenderer() { + @ParameterizedTest + @EnumSource(AudioOutputMode::class) + fun selectPlatformDecoders_routesAlacToExtensionRenderer(outputMode: AudioOutputMode) { val decoders = listOf("c2.qti.alac.decoder", "c2.android.alac.decoder") - val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_ALAC, decoders) + val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_ALAC, decoders, outputMode) assertThat(selected).isEmpty() } - @Test - fun selectPlatformDecoders_routesMidiToExtensionRenderer() { + @ParameterizedTest + @EnumSource(AudioOutputMode::class) + fun selectPlatformDecoders_routesMidiToExtensionRenderer(outputMode: AudioOutputMode) { val decoders = listOf("platform-midi-decoder") - val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_EXOPLAYER_MIDI, decoders) + val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_EXOPLAYER_MIDI, decoders, outputMode) assertThat(selected).isEmpty() } - @Test - fun selectPlatformDecoders_preservesMedia3OrderForCoreFormats() { + @ParameterizedTest + @EnumSource(AudioOutputMode::class) + fun selectPlatformDecoders_preservesMedia3OrderForCoreFormats(outputMode: AudioOutputMode) { val decoders = listOf("c2.android.aac.decoder", "c2.qti.aac.decoder") - val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_AAC, decoders) + val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_AAC, decoders, outputMode) assertThat(selected).containsExactlyElementsIn(decoders).inOrder() } + @Test + fun selectPlatformDecoders_routesFlacToExtensionRendererForFloatOutput() { + val decoders = listOf("c2.qti.flac.decoder", "c2.android.flac.decoder") + + val selected = AudioDecoderPolicy.selectPlatformDecoders( + MimeTypes.AUDIO_FLAC, decoders, AudioOutputMode.PCM_FLOAT + ) + + assertThat(selected).isEmpty() + } + + @ParameterizedTest + @EnumSource(value = AudioOutputMode::class, names = ["SYSTEM_DEFAULT", "DIRECT"]) + fun selectPlatformDecoders_preservesFlacDecodersForIntegerOutput(outputMode: AudioOutputMode) { + val decoders = listOf("c2.qti.flac.decoder", "c2.android.flac.decoder") + + val selected = AudioDecoderPolicy.selectPlatformDecoders(MimeTypes.AUDIO_FLAC, decoders, outputMode) + + assertThat(selected).containsExactlyElementsIn(decoders).inOrder() + } + + @Test + fun selectPlatformDecoders_matchesFlacMimeTypeIgnoringCase() { + val selected = AudioDecoderPolicy.selectPlatformDecoders( + "AUDIO/FLAC", listOf("c2.qti.flac.decoder"), AudioOutputMode.PCM_FLOAT + ) + + assertThat(selected).isEmpty() + } + @Test fun isLikelyHardwareDecoder_marksSoftwareRenderersAsSoftware() { assertThat(AudioDecoderPolicy.isLikelyHardwareDecoder("OMX.google.aac.decoder")).isFalse()