diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt index f9b675e..87d81ef 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt @@ -84,6 +84,10 @@ class AudioEngine( pendingSkips.incrementAndGet() } + fun setVolume(gain: Float) { + currentAudioTrack?.setVolume(gain.coerceIn(0f, 1f)) + } + private fun runPipeline() { Log.i(TAG, "runPipeline() connecting to $url") val connection = StreamConnection(url) diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt index 349cb3e..5954ce9 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -5,6 +5,9 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -114,6 +117,34 @@ class RadioPlaybackService : MediaLibraryService() { 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) { Log.i(TAG, "transition → $newState") controller.updateState(newState) @@ -127,6 +158,7 @@ class RadioPlaybackService : MediaLibraryService() { override fun onCreate() { super.onCreate() + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager ensureForegroundChannel() ensureMediaSession() } @@ -138,6 +170,41 @@ class RadioPlaybackService : MediaLibraryService() { 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() { val notification = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) @@ -199,6 +266,8 @@ class RadioPlaybackService : MediaLibraryService() { } private fun cleanupResources() { + stopForeground(STOP_FOREGROUND_REMOVE) + abandonAudioFocus() engine?.stop() engine = null releaseLocks() @@ -225,6 +294,14 @@ class RadioPlaybackService : MediaLibraryService() { playerAdapter?.updateStation(station) playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station)) + if (!requestAudioFocus()) { + Log.w(TAG, "Audio focus denied — not starting playback") + transition(PlaybackState.Idle) + cleanupResources() + stopSelf() + return + } + try { val urls = app.streamResolver.resolveUrls(station) Log.i(TAG, "handlePlay resolved ${urls.size} URLs for '${station.name}': ${urls.take(3)}") @@ -276,6 +353,7 @@ class RadioPlaybackService : MediaLibraryService() { } private fun acquireLocks() { + releaseLocks() val pm = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Radio247:Playback").apply { acquire(10 * 60 * 1000L) diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt new file mode 100644 index 0000000..23a1eb2 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt @@ -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) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt b/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt index c358d4a..ef2fe3b 100644 --- a/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt +++ b/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt @@ -160,4 +160,29 @@ class PlaybackStateMachineTest { 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) + } }