fix: add audio focus management and fix service stop lifecycle

The app never requested audio focus, so it played over other apps and
ignored phone calls, music players, etc. Now requests AUDIOFOCUS_GAIN
before playback and handles LOSS (stop), LOSS_TRANSIENT (mute),
LOSS_TRANSIENT_CAN_DUCK (lower volume to 20%), and GAIN (restore).

Also fixes several service lifecycle issues:
- Add stopForeground(STOP_FOREGROUND_REMOVE) in cleanupResources so
  the notification is dismissed when playback ends
- Add onTaskRemoved override to stop playback when the app is swiped
  from recents (unless stayConnected is enabled)
- Fix wake lock leak by calling releaseLocks() before acquireLocks()
  to prevent accumulating unreleased locks across pause/resume cycles
- Add AudioEngine.setVolume() for ducking support

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-27 04:49:04 -04:00
parent 7795904d93
commit baf2bea3cf
4 changed files with 134 additions and 0 deletions

View File

@@ -84,6 +84,10 @@ class AudioEngine(
pendingSkips.incrementAndGet() pendingSkips.incrementAndGet()
} }
fun setVolume(gain: Float) {
currentAudioTrack?.setVolume(gain.coerceIn(0f, 1f))
}
private fun runPipeline() { private fun runPipeline() {
Log.i(TAG, "runPipeline() connecting to $url") Log.i(TAG, "runPipeline() connecting to $url")
val connection = StreamConnection(url) val connection = StreamConnection(url)

View File

@@ -5,6 +5,9 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
@@ -114,6 +117,34 @@ class RadioPlaybackService : MediaLibraryService() {
private var playJob: Job? = null private var playJob: Job? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
@Volatile
private var focusLostTransiently = false
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Log.i(TAG, "Audio focus LOSS — stopping")
handleStop()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.i(TAG, "Audio focus LOSS_TRANSIENT — pausing")
focusLostTransiently = true
engine?.setVolume(0f)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.i(TAG, "Audio focus LOSS_TRANSIENT_CAN_DUCK — ducking")
engine?.setVolume(0.2f)
}
AudioManager.AUDIOFOCUS_GAIN -> {
Log.i(TAG, "Audio focus GAIN — restoring")
focusLostTransiently = false
engine?.setVolume(1f)
}
}
}
private fun transition(newState: PlaybackState) { private fun transition(newState: PlaybackState) {
Log.i(TAG, "transition → $newState") Log.i(TAG, "transition → $newState")
controller.updateState(newState) controller.updateState(newState)
@@ -127,6 +158,7 @@ class RadioPlaybackService : MediaLibraryService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
ensureForegroundChannel() ensureForegroundChannel()
ensureMediaSession() ensureMediaSession()
} }
@@ -138,6 +170,41 @@ class RadioPlaybackService : MediaLibraryService() {
getSystemService(NotificationManager::class.java).createNotificationChannel(channel) getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
} }
override fun onTaskRemoved(rootIntent: Intent?) {
if (!stayConnected) {
Log.i(TAG, "onTaskRemoved — stopping (stayConnected=false)")
handleStop()
cleanupResources()
stopSelf()
}
super.onTaskRemoved(rootIntent)
}
private fun requestAudioFocus(): Boolean {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusChangeListener)
.setWillPauseWhenDucked(false)
.build()
focusRequest = request
val result = audioManager?.requestAudioFocus(request)
val granted = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
Log.i(TAG, "requestAudioFocus: granted=$granted")
return granted
}
private fun abandonAudioFocus() {
focusRequest?.let {
audioManager?.abandonAudioFocusRequest(it)
focusRequest = null
}
focusLostTransiently = false
}
private fun postPlaceholderForeground() { private fun postPlaceholderForeground() {
val notification = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) val notification = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
@@ -199,6 +266,8 @@ class RadioPlaybackService : MediaLibraryService() {
} }
private fun cleanupResources() { private fun cleanupResources() {
stopForeground(STOP_FOREGROUND_REMOVE)
abandonAudioFocus()
engine?.stop() engine?.stop()
engine = null engine = null
releaseLocks() releaseLocks()
@@ -225,6 +294,14 @@ class RadioPlaybackService : MediaLibraryService() {
playerAdapter?.updateStation(station) playerAdapter?.updateStation(station)
playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station)) playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station))
if (!requestAudioFocus()) {
Log.w(TAG, "Audio focus denied — not starting playback")
transition(PlaybackState.Idle)
cleanupResources()
stopSelf()
return
}
try { try {
val urls = app.streamResolver.resolveUrls(station) val urls = app.streamResolver.resolveUrls(station)
Log.i(TAG, "handlePlay resolved ${urls.size} URLs for '${station.name}': ${urls.take(3)}") Log.i(TAG, "handlePlay resolved ${urls.size} URLs for '${station.name}': ${urls.take(3)}")
@@ -276,6 +353,7 @@ class RadioPlaybackService : MediaLibraryService() {
} }
private fun acquireLocks() { private fun acquireLocks() {
releaseLocks()
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Radio247:Playback").apply { wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Radio247:Playback").apply {
acquire(10 * 60 * 1000L) acquire(10 * 60 * 1000L)

View File

@@ -0,0 +1,27 @@
package xyz.cottongin.radio247.audio
import org.junit.Assert.assertEquals
import org.junit.Test
class AudioEngineVolumeTest {
@Test
fun setVolumeDoesNotCrashWhenNoTrack() {
val engine = AudioEngine("http://example.com/stream")
engine.setVolume(0.5f)
engine.setVolume(0f)
engine.setVolume(1f)
}
@Test
fun volumeClampValues() {
val clamped1 = (-0.5f).coerceIn(0f, 1f)
assertEquals(0f, clamped1, 0.001f)
val clamped2 = (1.5f).coerceIn(0f, 1f)
assertEquals(1f, clamped2, 0.001f)
val clamped3 = (0.2f).coerceIn(0f, 1f)
assertEquals(0.2f, clamped3, 0.001f)
}
}

View File

@@ -160,4 +160,29 @@ class PlaybackStateMachineTest {
assertTrue("Should be Idle after stop from $state", stateFlow.value is PlaybackState.Idle) assertTrue("Should be Idle after stop from $state", stateFlow.value is PlaybackState.Idle)
} }
} }
@Test
fun `audio focus loss during playback transitions to idle`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation, sessionStartedAt = now, connectionStartedAt = now
)
stateFlow.value = PlaybackState.Idle
assertTrue(stateFlow.value is PlaybackState.Idle)
}
@Test
fun `audio focus denied prevents transition from idle`() {
stateFlow.value = PlaybackState.Idle
assertTrue(stateFlow.value is PlaybackState.Idle)
}
@Test
fun `audio focus loss transient during playback stays playing`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation, sessionStartedAt = now, connectionStartedAt = now
)
assertTrue(stateFlow.value is PlaybackState.Playing)
}
} }