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
|
package xyz.cottongin.radio247.service
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
@@ -38,6 +39,8 @@ import kotlin.coroutines.coroutineContext
|
|||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
|
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
|
||||||
|
|
||||||
class RadioPlaybackService : LifecycleService() {
|
class RadioPlaybackService : LifecycleService() {
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
||||||
@@ -91,6 +94,10 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
|
|
||||||
private var playJob: Job? = null
|
private var playJob: Job? = null
|
||||||
|
|
||||||
|
private fun transition(newState: PlaybackState) {
|
||||||
|
controller.updateState(newState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationHelper.createChannel()
|
notificationHelper.createChannel()
|
||||||
@@ -131,7 +138,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
if (station != null) {
|
if (station != null) {
|
||||||
handlePlay(station, reuseSession = isResume)
|
handlePlay(station, reuseSession = isResume)
|
||||||
} else {
|
} else {
|
||||||
controller.updateState(PlaybackState.Idle)
|
transition(PlaybackState.Idle)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +178,8 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
startForegroundWithPlaceholder(station)
|
startForegroundWithPlaceholder(station)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
startEngine(station)
|
val urls = app.streamResolver.resolveUrls(station)
|
||||||
|
startEngine(station, urls)
|
||||||
if (stayConnected) {
|
if (stayConnected) {
|
||||||
reconnectLoop(station)
|
reconnectLoop(station)
|
||||||
}
|
}
|
||||||
@@ -181,22 +189,21 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
endConnectionSpan()
|
endConnectionSpan()
|
||||||
when (controller.state.value) {
|
val currentState = controller.state.value
|
||||||
is PlaybackState.Paused -> {
|
when {
|
||||||
|
currentState is PlaybackState.Paused -> {
|
||||||
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
|
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
|
||||||
}
|
}
|
||||||
is PlaybackState.Idle -> {
|
else -> {
|
||||||
endListeningSession()
|
|
||||||
val isActiveJob = playJob == coroutineContext[Job]
|
val isActiveJob = playJob == coroutineContext[Job]
|
||||||
if (isActiveJob) {
|
if (isActiveJob) {
|
||||||
|
transition(PlaybackState.Idle)
|
||||||
|
endListeningSession()
|
||||||
cleanupResources()
|
cleanupResources()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
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>()
|
val deferred = CompletableDeferred<Unit>()
|
||||||
reconnectionMutex.withLock {
|
val connectionStartedAt = System.currentTimeMillis()
|
||||||
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()
|
|
||||||
|
|
||||||
controller.updateState(
|
serviceScope.launch {
|
||||||
PlaybackState.Playing(
|
engine!!.events.collect { event ->
|
||||||
station = station,
|
when (event) {
|
||||||
metadata = null,
|
is AudioEngineEvent.Started -> {
|
||||||
sessionStartedAt = sessionStartedAt,
|
transition(
|
||||||
connectionStartedAt = connectionStartedAt
|
PlaybackState.Playing(
|
||||||
)
|
station = station,
|
||||||
)
|
metadata = null,
|
||||||
updateNotification(station, null, false)
|
sessionStartedAt = sessionStartedAt,
|
||||||
|
connectionStartedAt = connectionStartedAt
|
||||||
engine!!.start()
|
)
|
||||||
|
)
|
||||||
serviceScope.launch collector@ {
|
updateNotification(station, null, false)
|
||||||
engine!!.events.collect { event ->
|
controller.updateLatency(engine!!.estimatedLatencyMs)
|
||||||
when (event) {
|
}
|
||||||
is AudioEngineEvent.MetadataChanged -> {
|
is AudioEngineEvent.MetadataChanged -> {
|
||||||
currentMetadata = event.metadata
|
currentMetadata = event.metadata
|
||||||
val playingState = controller.state.value
|
val playingState = controller.state.value
|
||||||
if (playingState is PlaybackState.Playing) {
|
if (playingState is PlaybackState.Playing) {
|
||||||
controller.updateState(
|
transition(playingState.copy(metadata = event.metadata))
|
||||||
playingState.copy(metadata = event.metadata)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updateNotification(station, event.metadata, false)
|
|
||||||
persistMetadataSnapshot(station.id, event.metadata)
|
|
||||||
}
|
}
|
||||||
is AudioEngineEvent.StreamInfoReceived -> {
|
updateNotification(station, event.metadata, false)
|
||||||
val playingState = controller.state.value
|
persistMetadataSnapshot(station.id, event.metadata)
|
||||||
if (playingState is PlaybackState.Playing) {
|
}
|
||||||
controller.updateState(
|
is AudioEngineEvent.StreamInfoReceived -> {
|
||||||
playingState.copy(streamInfo = event.streamInfo)
|
val playingState = controller.state.value
|
||||||
)
|
if (playingState is PlaybackState.Playing) {
|
||||||
}
|
transition(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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()
|
deferred.await()
|
||||||
@@ -344,7 +374,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
retryImmediatelyOnNetwork = false
|
retryImmediatelyOnNetwork = false
|
||||||
} else {
|
} else {
|
||||||
attempt++
|
attempt++
|
||||||
controller.updateState(
|
transition(
|
||||||
PlaybackState.Reconnecting(
|
PlaybackState.Reconnecting(
|
||||||
station = station,
|
station = station,
|
||||||
metadata = currentMetadata,
|
metadata = currentMetadata,
|
||||||
@@ -363,7 +393,8 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
if (!stayConnected) break
|
if (!stayConnected) break
|
||||||
try {
|
try {
|
||||||
startEngine(station)
|
val urls = app.streamResolver.resolveUrls(station)
|
||||||
|
startEngine(station, urls)
|
||||||
return
|
return
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Continue loop
|
// Continue loop
|
||||||
|
|||||||
Reference in New Issue
Block a user