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 1680d44..55d076a 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -1,6 +1,7 @@ package xyz.cottongin.radio247.service import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.Intent import android.net.ConnectivityManager @@ -38,6 +39,8 @@ import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job +class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause) + class RadioPlaybackService : LifecycleService() { companion object { const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY" @@ -91,6 +94,10 @@ class RadioPlaybackService : LifecycleService() { private var playJob: Job? = null + private fun transition(newState: PlaybackState) { + controller.updateState(newState) + } + override fun onCreate() { super.onCreate() notificationHelper.createChannel() @@ -131,7 +138,7 @@ class RadioPlaybackService : LifecycleService() { if (station != null) { handlePlay(station, reuseSession = isResume) } else { - controller.updateState(PlaybackState.Idle) + transition(PlaybackState.Idle) stopSelf() } } @@ -171,7 +178,8 @@ class RadioPlaybackService : LifecycleService() { startForegroundWithPlaceholder(station) try { - startEngine(station) + val urls = app.streamResolver.resolveUrls(station) + startEngine(station, urls) if (stayConnected) { reconnectLoop(station) } @@ -181,22 +189,21 @@ class RadioPlaybackService : LifecycleService() { } } finally { endConnectionSpan() - when (controller.state.value) { - is PlaybackState.Paused -> { + val currentState = controller.state.value + when { + currentState is PlaybackState.Paused -> { updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true) } - is PlaybackState.Idle -> { - endListeningSession() + else -> { val isActiveJob = playJob == coroutineContext[Job] if (isActiveJob) { + transition(PlaybackState.Idle) + endListeningSession() cleanupResources() - stopForeground(STOP_FOREGROUND_REMOVE) + stopForeground(Service.STOP_FOREGROUND_REMOVE) stopSelf() } } - else -> { - // Connecting to a new station or other transition -- another job takes over - } } } } @@ -256,81 +263,104 @@ class RadioPlaybackService : LifecycleService() { } } - private suspend fun startEngine(station: Station) { + private suspend fun startEngine(station: Station, urls: List) { + for ((index, url) in urls.withIndex()) { + reconnectionMutex.withLock { + engine?.stop() + transition( + PlaybackState.Connecting( + station = station, + urls = urls, + currentUrlIndex = index, + sessionStartedAt = sessionStartedAt + ) + ) + updateNotification(station, null, isReconnecting = false) + + val bufferMs = app.preferences.bufferMs.first() + engine = AudioEngine(url, bufferMs) + connectionSpanId = connectionSpanDao.insert( + ConnectionSpan( + sessionId = listeningSessionId, + startedAt = System.currentTimeMillis() + ) + ) + currentMetadata = null + engine!!.start() + } + + try { + awaitEngine(station) + return + } catch (e: Exception) { + endConnectionSpan() + if (e is ConnectionFailedException && index < urls.size - 1) { + continue + } + throw e + } + } + throw Exception("All URLs exhausted") + } + + private suspend fun awaitEngine(station: Station) { val deferred = CompletableDeferred() - reconnectionMutex.withLock { - engine?.stop() - val bufferMs = app.preferences.bufferMs.first() - val urls = app.streamResolver.resolveUrls(station) - engine = AudioEngine(urls.first(), bufferMs) - connectionSpanId = connectionSpanDao.insert( - ConnectionSpan( - sessionId = listeningSessionId, - startedAt = System.currentTimeMillis() - ) - ) - currentMetadata = null - val connectionStartedAt = System.currentTimeMillis() + val connectionStartedAt = System.currentTimeMillis() - controller.updateState( - PlaybackState.Playing( - station = station, - metadata = null, - sessionStartedAt = sessionStartedAt, - connectionStartedAt = connectionStartedAt - ) - ) - updateNotification(station, null, false) - - engine!!.start() - - serviceScope.launch collector@ { - engine!!.events.collect { event -> - when (event) { - is AudioEngineEvent.MetadataChanged -> { - currentMetadata = event.metadata - val playingState = controller.state.value - if (playingState is PlaybackState.Playing) { - controller.updateState( - playingState.copy(metadata = event.metadata) - ) - } - updateNotification(station, event.metadata, false) - persistMetadataSnapshot(station.id, event.metadata) + serviceScope.launch { + engine!!.events.collect { event -> + when (event) { + is AudioEngineEvent.Started -> { + transition( + PlaybackState.Playing( + station = station, + metadata = null, + sessionStartedAt = sessionStartedAt, + connectionStartedAt = connectionStartedAt + ) + ) + updateNotification(station, null, false) + controller.updateLatency(engine!!.estimatedLatencyMs) + } + is AudioEngineEvent.MetadataChanged -> { + currentMetadata = event.metadata + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + transition(playingState.copy(metadata = event.metadata)) } - is AudioEngineEvent.StreamInfoReceived -> { - val playingState = controller.state.value - if (playingState is PlaybackState.Playing) { - controller.updateState( - playingState.copy(streamInfo = event.streamInfo) - ) - } - } - is AudioEngineEvent.Started -> { - controller.updateLatency(engine!!.estimatedLatencyMs) - } - is AudioEngineEvent.Error -> { - endConnectionSpan() - engine?.stop() - engine = null - val throwable = when (val cause = event.cause) { - is EngineError.ConnectionFailed -> cause.cause - is EngineError.StreamEnded -> Exception("Stream ended") - is EngineError.DecoderError -> cause.cause - is EngineError.AudioOutputError -> cause.cause - } - deferred.completeExceptionally(throwable) - return@collect - } - is AudioEngineEvent.Stopped -> { - deferred.complete(Unit) - return@collect + updateNotification(station, event.metadata, false) + persistMetadataSnapshot(station.id, event.metadata) + } + is AudioEngineEvent.StreamInfoReceived -> { + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + transition(playingState.copy(streamInfo = event.streamInfo)) } } + is AudioEngineEvent.Error -> { + engine?.stop() + engine = null + val throwable = when (val cause = event.cause) { + is EngineError.ConnectionFailed -> + ConnectionFailedException(cause.cause) + is EngineError.StreamEnded -> + Exception("Stream ended") + is EngineError.DecoderError -> + cause.cause + is EngineError.AudioOutputError -> + cause.cause + } + deferred.completeExceptionally(throwable) + return@collect + } + is AudioEngineEvent.Stopped -> { + deferred.complete(Unit) + return@collect + } } - if (!deferred.isCompleted) { - deferred.completeExceptionally(Exception("Event flow completed unexpectedly")) - } + } + if (!deferred.isCompleted) { + deferred.completeExceptionally(Exception("Event flow completed unexpectedly")) } } deferred.await() @@ -344,7 +374,7 @@ class RadioPlaybackService : LifecycleService() { retryImmediatelyOnNetwork = false } else { attempt++ - controller.updateState( + transition( PlaybackState.Reconnecting( station = station, metadata = currentMetadata, @@ -363,7 +393,8 @@ class RadioPlaybackService : LifecycleService() { } if (!stayConnected) break try { - startEngine(station) + val urls = app.streamResolver.resolveUrls(station) + startEngine(station, urls) return } catch (_: Exception) { // Continue loop