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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user