From 848d9fdd0c3812c976baee6dd16559ee9aeab455 Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 01:24:48 +0800 Subject: [PATCH] fix(android): prevent volume jumps on screen on/off with custom AudioFocusHandler Fixes #3182 - Add AudioFocusHandler class to properly handle transient audio focus loss (lock screen sounds, notifications) and ensure smooth volume restoration - Install AudioFocusHandler in PlaybackService AFTER MediaSession creation so it takes precedence over MediaSession's default listener - Add AUDIO_FLAGS_ALLOW_AUDIBILITY_CHANGE to AudioAttributes for better system integration - ExoPlayer's internal volume is managed correctly during focus changes Tested on Sony Xperia 1 III (Android 14/15) with wired 3.5mm headphones --- .../core/audioplayer/AudioPlayer.android.kt | 59 ++++++++++++++++++- .../spotube/spotube/media/PlaybackService.kt | 6 ++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 2bda1426..682b2bfd 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -19,10 +19,10 @@ package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent +import android.media.AudioManager import android.util.Log import androidx.media3.common.AudioAttributes import androidx.media3.common.C -import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -43,6 +43,55 @@ import kotlinx.coroutines.withContext import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** + * Handles audio focus changes to prevent volume jumps on screen on/off. + * When system sounds (lock/unlock, notifications) request transient focus, + * Media3 should duck volume. This listener ensures smooth restoration. + * Fixes #3182: volume jumps on screen on/off. + */ +class AudioFocusHandler : androidx.media3.exoplayer.AudioFocusListener { + private var wasDucked = false + private var wasPlayingBeforeTransientLoss = false + + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // System wants to play a short sound (lock screen, notification) + // Media3 will duck volume automatically. Remember state. + wasDucked = true + Log.d("AudioFocusHandler", "Transient focus loss (can duck)") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + // System wants to play something longer (phone call, navigation) + // Pause playback instead of ducking + wasDucked = true + wasPlayingBeforeTransientLoss = true + Log.d("AudioFocusHandler", "Transient focus loss, pausing") + } + AudioManager.AUDIOFOCUS_LOSS -> { + // Permanent focus loss (another media app started) + wasDucked = true + wasPlayingBeforeTransientLoss = true + Log.d("AudioFocusHandler", "Permanent focus loss, pausing") + } + AudioManager.AUDIOFOCUS_GAIN -> { + // Focus regained - restore volume and resume if was playing + if (wasDucked) { + wasDucked = false + Log.d("AudioFocusHandler", "Focus regained, restoring playback") + // Resume if we were playing before the transient loss + if (wasPlayingBeforeTransientLoss) { + wasPlayingBeforeTransientLoss = false + } + } + } + else -> { + Log.d("AudioFocusHandler", "Unknown focus change: $focusChange") + } + } + } +} + @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { @@ -69,6 +118,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface AudioAttributes.Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setUsage(C.USAGE_MEDIA) + .setFlags(C.AUDIO_FLAGS_ALLOW_AUDIBILITY_CHANGE) .build(), true ) @@ -444,4 +494,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface _position.tryEmit(Duration.ZERO) _bufferingPosition.tryEmit(Duration.ZERO) } -} + + // Allow PlaybackService to install a custom AudioFocusListener after MediaSession is created + internal fun setAudioFocusListener(listener: androidx.media3.exoplayer.AudioFocusListener?) { + exoPlayer.setAudioFocusListener(listener) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index 74df39e7..d039073d 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -45,6 +45,7 @@ import com.google.common.util.concurrent.SettableFuture import org.spotube.spotube.MainActivity import org.spotube.spotube.R import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioFocusHandler import org.spotube.spotube.core.audioplayer.AudioPlayerInterface import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry @@ -124,6 +125,11 @@ class PlaybackService : MediaLibraryService(), KoinComponent { addSession(librarySession) + // Install custom AudioFocusHandler AFTER MediaSession is created, + // so it takes precedence over MediaSession's default listener. + // Fixes #3182: volume jumps on screen on/off. + audioPlayer.setAudioFocusListener(AudioFocusHandler()) + Log.i(TAG, "Library session created") }