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.Context
import android.content.Intent import android.content.Intent
import android.media.AudioManager
import android.util.Log import android.util.Log
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@ -43,6 +43,55 @@ import kotlinx.coroutines.withContext
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds 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") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
@ -69,6 +118,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
AudioAttributes.Builder() AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.setFlags(C.AUDIO_FLAGS_ALLOW_AUDIBILITY_CHANGE)
.build(), .build(),
true true
) )
@ -444,4 +494,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_position.tryEmit(Duration.ZERO) _position.tryEmit(Duration.ZERO)
_bufferingPosition.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.MainActivity
import org.spotube.spotube.R import org.spotube.spotube.R
import org.spotube.spotube.core.audioplayer.AudioPlayer 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.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
@ -124,6 +125,11 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
addSession(librarySession) 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") Log.i(TAG, "Library session created")
} }