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") } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 5041e371..939660f2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -102,7 +102,9 @@ class JamViewModel( codeCopied, snapshotFlow { codeInput.text.toString() }, participantMenu, - isLeaving + isLeaving, + // Local client ID to exclude self from moderation menu + jamRoomService.participantClientId ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @@ -116,6 +118,7 @@ class JamViewModel( val localError = values[6] as String? val busy = values[8] as Boolean val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank() + val localClientId = values[13] as String JamUiState( isActive = role != null, @@ -127,7 +130,8 @@ class JamViewModel( displayName = participant.displayName, initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", isHost = participant.isHost, - canModerate = role == JamRole.Host && !participant.isHost, + // Host can moderate OTHER participants only (not self) + canModerate = role == JamRole.Host && !participant.isHost && participant.id != localClientId, menuExpanded = values[11] == participant.id ) },