Refactor RadioPlaybackService: transition(), URL fallback, fixed finally

- Add transition() - all state changes route through it
- Add ConnectionFailedException for connection failures
- Refactor startEngine to accept urls: List<String>, iterate with Connecting state
- Extract awaitEngine() for event collection
- Set Connecting (not Playing) initially; Playing only on AudioEngineEvent.Started
- Fix handlePlay finally block: handle all states (Paused vs else)
- Update reconnectLoop to resolve URLs inside loop
- Add Service import for STOP_FOREGROUND_REMOVE

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-11 16:14:27 -04:00
parent 0d5992f4a3
commit 3240db829f

View File

@@ -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<String>) {
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<Unit>()
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