feat: add foreground playback service with Stay Connected reconnection

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:30:48 -04:00
parent cfb04d3200
commit ca7b757812
7 changed files with 525 additions and 7 deletions

View File

@@ -45,6 +45,7 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.activity)
implementation(libs.core.ktx)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.room.runtime)

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.room.Room
import xyz.cottongin.radio247.data.db.RadioDatabase
import xyz.cottongin.radio247.data.prefs.RadioPreferences
import xyz.cottongin.radio247.service.RadioController
class RadioApplication : Application() {
val database: RadioDatabase by lazy {
@@ -14,4 +15,8 @@ class RadioApplication : Application() {
val preferences: RadioPreferences by lazy {
RadioPreferences(this)
}
val controller: RadioController by lazy {
RadioController(this)
}
}

View File

@@ -0,0 +1,64 @@
package xyz.cottongin.radio247.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import android.support.v4.media.session.MediaSessionCompat
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.data.model.Station
class NotificationHelper(private val context: Context) {
companion object {
const val CHANNEL_ID = "radio_playback"
const val NOTIFICATION_ID = 1
}
fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Radio Playback",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "24/7 Radio playback"
}
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
fun buildNotification(
station: Station,
metadata: IcyMetadata?,
isReconnecting: Boolean,
mediaSession: MediaSessionCompat,
stopPendingIntent: PendingIntent
): Notification {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(station.name)
.setContentText(
when {
isReconnecting -> "Reconnecting..."
metadata?.title != null -> metadata.raw
else -> "Playing"
}
)
.setSmallIcon(R.drawable.ic_radio_placeholder)
.setOngoing(true)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0)
)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_media_pause,
"Stop",
stopPendingIntent
).build()
)
.build()
}
}

View File

@@ -0,0 +1,20 @@
package xyz.cottongin.radio247.service
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.data.model.Station
sealed interface PlaybackState {
data object Idle : PlaybackState
data class Playing(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long = System.currentTimeMillis(),
val connectionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
data class Reconnecting(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long,
val attempt: Int = 1
) : PlaybackState
}

View File

@@ -0,0 +1,42 @@
package xyz.cottongin.radio247.service
import android.content.Intent
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class RadioController(
private val application: RadioApplication
) {
private val _state = MutableStateFlow<PlaybackState>(PlaybackState.Idle)
val state: StateFlow<PlaybackState> = _state.asStateFlow()
private val _estimatedLatencyMs = MutableStateFlow(0L)
val estimatedLatencyMs: StateFlow<Long> = _estimatedLatencyMs.asStateFlow()
fun play(station: Station) {
val intent = Intent(application, RadioPlaybackService::class.java).apply {
action = RadioPlaybackService.ACTION_PLAY
putExtra(RadioPlaybackService.EXTRA_STATION_ID, station.id)
}
application.startForegroundService(intent)
}
fun stop() {
val intent = Intent(application, RadioPlaybackService::class.java).apply {
action = RadioPlaybackService.ACTION_STOP
}
application.startService(intent)
}
// Called by the service to update state
internal fun updateState(state: PlaybackState) {
_state.value = state
}
internal fun updateLatency(ms: Long) {
_estimatedLatencyMs.value = ms
}
}

View File

@@ -1,13 +1,398 @@
package xyz.cottongin.radio247.service
import android.app.Service
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.IBinder
import android.os.PowerManager
import androidx.lifecycle.LifecycleService
import android.support.v4.media.session.MediaSessionCompat
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.audio.AudioEngine
import xyz.cottongin.radio247.audio.AudioEngineEvent
import xyz.cottongin.radio247.audio.EngineError
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.data.db.ConnectionSpanDao
import xyz.cottongin.radio247.data.db.ListeningSessionDao
import xyz.cottongin.radio247.data.db.MetadataSnapshotDao
import xyz.cottongin.radio247.data.db.StationDao
import xyz.cottongin.radio247.data.model.ConnectionSpan
import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CompletableDeferred
/**
* Stub for the media playback foreground service.
* Will be implemented in a later task.
*/
class RadioPlaybackService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
class RadioPlaybackService : LifecycleService() {
companion object {
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
const val EXTRA_STATION_ID = "station_id"
}
private val app: RadioApplication
get() = application as RadioApplication
private val controller: RadioController
get() = app.controller
private val database get() = app.database
private val stationDao: StationDao get() = database.stationDao()
private val listeningSessionDao: ListeningSessionDao get() = database.listeningSessionDao()
private val connectionSpanDao: ConnectionSpanDao get() = database.connectionSpanDao()
private val metadataSnapshotDao: MetadataSnapshotDao get() = database.metadataSnapshotDao()
private val notificationHelper = NotificationHelper(this)
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val reconnectionMutex = Mutex()
private var mediaSession: MediaSessionCompat? = null
private var engine: AudioEngine? = null
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
@Volatile
private var stayConnected = false
@Volatile
private var sessionStartedAt: Long = 0L
@Volatile
private var connectionSpanId: Long = 0L
@Volatile
private var listeningSessionId: Long = 0L
@Volatile
private var currentMetadata: IcyMetadata? = null
@Volatile
private var networkAvailableCallback: ConnectivityManager.NetworkCallback? = null
@Volatile
private var retryImmediatelyOnNetwork = false
override fun onCreate() {
super.onCreate()
notificationHelper.createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PLAY -> {
val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L)
if (stationId >= 0) {
serviceScope.launch {
val station = stationDao.getStationById(stationId)
if (station != null) {
handlePlay(station)
} else {
stopSelf()
}
}
} else {
stopSelf()
}
}
ACTION_STOP -> handleStop()
else -> stopSelf()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? = null
override fun onDestroy() {
cleanup()
serviceScope.cancel()
super.onDestroy()
}
private fun cleanup() {
engine?.stop()
engine = null
releaseLocks()
mediaSession?.release()
mediaSession = null
unregisterNetworkCallback()
controller.updateState(PlaybackState.Idle)
controller.updateLatency(0)
}
private suspend fun handlePlay(station: Station) {
stayConnected = app.preferences.stayConnected.first()
sessionStartedAt = System.currentTimeMillis()
listeningSessionId = listeningSessionDao.insert(
ListeningSession(
stationId = station.id,
startedAt = sessionStartedAt
)
)
acquireLocks()
ensureMediaSession()
startForegroundWithPlaceholder(station)
try {
startEngine(station)
if (stayConnected) {
reconnectLoop(station)
}
} catch (_: Exception) {
if (stayConnected) {
reconnectLoop(station)
}
} finally {
endConnectionSpan()
endListeningSession()
cleanup()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun handleStop() {
stayConnected = false
retryImmediatelyOnNetwork = false
engine?.stop()
}
private fun acquireLocks() {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Radio247:Playback").apply {
acquire(10 * 60 * 1000L)
}
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as? android.net.wifi.WifiManager
wifiLock = wifiManager?.createWifiLock(android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Radio247:Playback")?.apply {
acquire()
}
}
private fun releaseLocks() {
wakeLock?.let {
if (it.isHeld) it.release()
wakeLock = null
}
wifiLock?.let {
if (it.isHeld) it.release()
wifiLock = null
}
}
private fun ensureMediaSession() {
if (mediaSession == null) {
mediaSession = MediaSessionCompat(this, "Radio247").apply {
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
setCallback(object : MediaSessionCompat.Callback() {
override fun onStop() {
this@RadioPlaybackService.controller.stop()
}
})
isActive = true
}
}
}
private suspend fun startEngine(station: Station) {
val deferred = CompletableDeferred<Unit>()
reconnectionMutex.withLock {
engine?.stop()
val bufferMs = app.preferences.bufferMs.first()
engine = AudioEngine(station.url, bufferMs)
connectionSpanId = connectionSpanDao.insert(
ConnectionSpan(
sessionId = listeningSessionId,
startedAt = System.currentTimeMillis()
)
)
currentMetadata = null
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@ {
try {
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)
}
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)
}
is AudioEngineEvent.Stopped -> {
deferred.complete(Unit)
}
}
}
} catch (e: Exception) {
if (!deferred.isCompleted) {
deferred.completeExceptionally(e)
}
}
}
}
deferred.await()
}
private suspend fun reconnectLoop(station: Station) {
var attempt = 0
registerNetworkCallback()
while (stayConnected) {
if (retryImmediatelyOnNetwork) {
retryImmediatelyOnNetwork = false
} else {
attempt++
controller.updateState(
PlaybackState.Reconnecting(
station = station,
metadata = currentMetadata,
sessionStartedAt = sessionStartedAt,
attempt = attempt
)
)
updateNotification(station, currentMetadata, isReconnecting = true)
val delayMs = minOf(1000L * (1 shl (attempt - 1)), 30_000L)
val chunk = 500L
var remaining = delayMs
while (remaining > 0 && !retryImmediatelyOnNetwork && stayConnected) {
delay(minOf(chunk, remaining))
remaining -= chunk
}
}
if (!stayConnected) break
try {
startEngine(station)
return
} catch (_: Exception) {
// Continue loop
}
}
}
private fun registerNetworkCallback() {
if (networkAvailableCallback != null) return
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
networkAvailableCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
retryImmediatelyOnNetwork = true
}
}
cm.registerNetworkCallback(request, networkAvailableCallback!!)
}
private fun unregisterNetworkCallback() {
networkAvailableCallback?.let { callback ->
try {
(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager)
.unregisterNetworkCallback(callback)
} catch (_: Exception) {}
networkAvailableCallback = null
}
}
private fun startForegroundWithPlaceholder(station: Station) {
updateNotification(station, null, isReconnecting = false)
}
private fun updateNotification(station: Station, metadata: IcyMetadata?, isReconnecting: Boolean) {
val session = mediaSession ?: return
val stopIntent = Intent(this, RadioPlaybackService::class.java).apply {
action = ACTION_STOP
}
val stopPendingIntent = PendingIntent.getService(
this, 0, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = notificationHelper.buildNotification(
station = station,
metadata = metadata,
isReconnecting = isReconnecting,
mediaSession = session,
stopPendingIntent = stopPendingIntent
)
startForeground(NotificationHelper.NOTIFICATION_ID, notification)
}
private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) {
withContext(Dispatchers.IO) {
metadataSnapshotDao.insert(
MetadataSnapshot(
stationId = stationId,
title = metadata.title,
artist = metadata.artist,
artworkUrl = null,
timestamp = System.currentTimeMillis()
)
)
}
}
private fun endConnectionSpan() {
if (connectionSpanId != 0L) {
CoroutineScope(Dispatchers.IO).launch {
connectionSpanDao.updateEndedAt(connectionSpanId, System.currentTimeMillis())
}
connectionSpanId = 0L
}
}
private fun endListeningSession() {
if (listeningSessionId != 0L) {
CoroutineScope(Dispatchers.IO).launch {
listeningSessionDao.updateEndedAt(listeningSessionId, System.currentTimeMillis())
}
listeningSessionId = 0L
}
}
}

View File

@@ -21,6 +21,7 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.10.1" }
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }