Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.AccessTime
import androidx.compose.material.icons.rounded.BrightnessMedium
import androidx.compose.material.icons.rounded.Cast
import androidx.compose.material.icons.rounded.Details
import androidx.compose.material.icons.rounded.VolumeUp
import androidx.compose.material.icons.rounded.FlashOn
import androidx.compose.material.icons.rounded.Loop
import androidx.compose.material.icons.rounded.PictureInPicture
Expand Down Expand Up @@ -65,6 +66,18 @@ internal fun OptionalFragment(
onChanged = { tunneling = !tunneling }
)
}
item {
var disableAudioPassthrough by mutablePreferenceOf(
PreferencesKeys.DISABLE_AUDIO_PASSTHROUGH
)
SwitchSharedPreference(
title = string.feat_setting_disable_audio_passthrough,
content = string.feat_setting_disable_audio_passthrough_description,
icon = Icons.Rounded.VolumeUp,
checked = disableAudioPassthrough,
onChanged = { disableAudioPassthrough = !disableAudioPassthrough }
)
}
item {
var fullInfoPlayer by mutablePreferenceOf(PreferencesKeys.FULL_INFO_PLAYER)
SwitchSharedPreference(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ private val PREFERENCES: Map<Preferences.Key<*>, Any> = buildMap {
put(PreferencesKeys.THEME_STYLE, ThemePreference.DEFAULT.style)
put(PreferencesKeys.THEME_PRESET_ID, ThemePreference.DEFAULT.presetId)
put(PreferencesKeys.TUNNELING, false)
put(PreferencesKeys.DISABLE_AUDIO_PASSTHROUGH, false)
put(PreferencesKeys.CLOCK_MODE, false)
put(PreferencesKeys.REMOTE_CONTROL, false)
put(PreferencesKeys.SLIDER, true)
Expand Down Expand Up @@ -235,6 +236,20 @@ object PreferencesKeys {
val THEME_STYLE = intPreferencesKey("theme-style")
val THEME_PRESET_ID = stringPreferencesKey("theme-preset-id")
val TUNNELING = booleanPreferencesKey("tunneling")

/**
* Decode compressed audio locally instead of passing it through untouched.
*
* Some devices — Amlogic TV boxes in particular — route AC3/EAC3 straight to
* the HDMI sink, bypassing the mixer entirely. Playback works, but the
* volume keys no longer affect it: a stereo channel responds while a 5.1
* movie stays at a fixed level, which looks like a broken remote.
*
* Off by default: passthrough is the right choice when an A/V receiver is
* doing the decoding, and turning it off would cost those users their
* surround track.
*/
val DISABLE_AUDIO_PASSTHROUGH = booleanPreferencesKey("disable-audio-passthrough")
val CLOCK_MODE = booleanPreferencesKey("12h-clock-mode")
val REMOTE_CONTROL = booleanPreferencesKey("remote-control")

Expand Down
86 changes: 84 additions & 2 deletions data/src/main/java/com/m3u/data/service/internal/Codecs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,96 @@ package com.m3u.data.service.internal
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import android.os.Handler
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder

object Codecs {
fun createRenderersFactory(context: Context): RenderersFactory {
return NextRenderersFactory(context).apply {
/**
* @param disableAudioPassthrough decode compressed audio locally instead of
* handing it to the sink untouched.
*
* ExoPlayer asks the audio output what it can handle, and when the sink
* claims AC3 or EAC3 support it forwards the stream as-is. That is the right
* call with an A/V receiver, but on devices where the box itself feeds the
* speakers it has an unwanted side effect: the stream never reaches the
* mixer, so the volume keys stop working on it. Users see a stereo channel
* respond normally while a 5.1 movie ignores the remote entirely.
*
* Declaring stereo-only capabilities makes ExoPlayer decode instead — the
* FFmpeg renderers below already handle AC3 and EAC3 — and audio flows back
* through the mixer where volume applies.
*/
@OptIn(UnstableApi::class)
fun createRenderersFactory(
context: Context,
disableAudioPassthrough: Boolean = false
): RenderersFactory {
val factory = if (disableAudioPassthrough) {
object : NextRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink = DefaultAudioSink.Builder()
// Deliberately built WITHOUT a Context. DefaultAudioSink only
// honours the capabilities set here when no Context is given —
// otherwise it probes the actual output and silently discards
// them, which is exactly what happened on the first attempt.
.setAudioCapabilities(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.build()

/**
* Route audio decoding to the bundled FFmpeg renderers, and only
* audio.
*
* Hardware AC3 decoders are built to feed a passthrough path;
* asked for PCM instead, some produce no output at all — the
* stream plays, the mixer runs, and nothing is audible.
*
* The mode is overridden here rather than on the factory because
* setExtensionRendererMode applies to every renderer: raising it
* globally also hands video to the software decoders, which on a
* modest TV box turns smooth playback into a slideshow. Measured,
* not assumed.
*/
override fun buildAudioRenderers(
context: Context,
extensionRendererMode: Int,
mediaCodecSelector: MediaCodecSelector,
enableDecoderFallback: Boolean,
audioSink: AudioSink,
eventHandler: Handler,
eventListener: AudioRendererEventListener,
out: ArrayList<Renderer>
) {
super.buildAudioRenderers(
context,
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER,
mediaCodecSelector,
enableDecoderFallback,
audioSink,
eventHandler,
eventListener,
out
)
}
}
} else {
NextRenderersFactory(context)
}
return factory.apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ class PlayerManagerImpl @Inject constructor(
if (!providerSessionState.isCurrent(generation)) return
val rtmp: Boolean = Url(url).protocol.name == "rtmp"
val tunneling = settings[PreferencesKeys.TUNNELING]
val disableAudioPassthrough = settings[PreferencesKeys.DISABLE_AUDIO_PASSTHROUGH]

val mimeType = when (val chain = chain) {
is MimetypeChain.Remembered -> chain.mimeType
Expand Down Expand Up @@ -450,6 +451,7 @@ class PlayerManagerImpl @Inject constructor(
mediaSource = mediaSource,
mediaExtractor = mediaExtractor,
tunneling = tunneling,
disableAudioPassthrough = disableAudioPassthrough,
) ?: return
mainCoroutineScope.launch {
if (!providerSessionState.isCurrent(generation)) return@launch
Expand Down Expand Up @@ -612,10 +614,13 @@ class PlayerManagerImpl @Inject constructor(
private fun createPlayer(
mediaSourceFactory: MediaSource.Factory,
tunneling: Boolean,
disableAudioPassthrough: Boolean,
listener: Player.Listener,
): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory)
.setRenderersFactory(
Codecs.createRenderersFactory(context, disableAudioPassthrough)
)
.setTrackSelector(createTrackSelector(tunneling))
.setHandleAudioBecomingNoisy(true)
.build()
Expand All @@ -635,6 +640,7 @@ class PlayerManagerImpl @Inject constructor(
mediaSource: MediaSource,
mediaExtractor: MediaExtractorCompat,
tunneling: Boolean,
disableAudioPassthrough: Boolean,
): ExoPlayer? {
var result: ExoPlayer? = null
providerSessionState.runIfCurrent(generation) {
Expand All @@ -644,6 +650,7 @@ class PlayerManagerImpl @Inject constructor(
createPlayer(
mediaSourceFactory = mediaSourceFactory,
tunneling = tunneling,
disableAudioPassthrough = disableAudioPassthrough,
listener = listener,
).also { createdPlayer ->
timber.d("player instance updated")
Expand Down Expand Up @@ -686,9 +693,9 @@ class PlayerManagerImpl @Inject constructor(
}
}

private val renderersFactory: RenderersFactory by lazy {
Codecs.createRenderersFactory(context)
}
// The renderers factory is no longer cached: the audio decoding preference
// can change between two playbacks, and a remembered instance would keep
// applying the earlier choice with nothing to show for it.

private fun createTrackSelector(tunneling: Boolean): TrackSelector {
return DefaultTrackSelector(context).apply {
Expand Down
2 changes: 2 additions & 0 deletions i18n/src/main/res/values/feat_setting.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@
<string name="feat_setting_theme_ink">Ink</string>
<string name="feat_setting_tunneling">tunneling mode</string>
<string name="feat_setting_tunneling_description">improve playback of 4K/HDR content</string>
<string name="feat_setting_disable_audio_passthrough">decode audio locally</string>
<string name="feat_setting_disable_audio_passthrough_description">restores volume control when compressed audio (AC3/EAC3) is sent untouched to the TV. Turn off if an A/V receiver handles surround decoding</string>
<string name="feat_setting_label_backup">Back up</string>
<string name="feat_setting_label_restore">Restore</string>
<string name="feat_setting_canvas_dark">dark</string>
Expand Down
Loading