From ca7b7578128992b119d416bf2c579208ac428083 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 02:30:48 -0400 Subject: [PATCH] feat: add foreground playback service with Stay Connected reconnection Made-with: Cursor --- app/build.gradle.kts | 1 + .../cottongin/radio247/RadioApplication.kt | 5 + .../radio247/service/NotificationHelper.kt | 64 +++ .../radio247/service/PlaybackState.kt | 20 + .../radio247/service/RadioController.kt | 42 ++ .../radio247/service/RadioPlaybackService.kt | 399 +++++++++++++++++- gradle/libs.versions.toml | 1 + 7 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 627db9b..e78f6d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt b/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt index 52b7867..c5e713c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt +++ b/app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt @@ -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) + } } diff --git a/app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt b/app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt new file mode 100644 index 0000000..ab341f6 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt @@ -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() + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt b/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt new file mode 100644 index 0000000..6bf371b --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt @@ -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 +} diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt new file mode 100644 index 0000000..2c9f7ce --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt @@ -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.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _estimatedLatencyMs = MutableStateFlow(0L) + val estimatedLatencyMs: StateFlow = _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 + } +} 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 44549dd..2728854 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -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() + 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 + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3344c8..fabbe57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }