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