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
This commit is contained in:
thumb2086 2026-10-31 01:24:48 +08:00
parent 35700920b4
commit 848d9fdd0c
2 changed files with 63 additions and 2 deletions

View File

@ -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)
}
}

View File

@ -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")
}