feat: add foreground playback service with Stay Connected reconnection
Made-with: Cursor
This commit is contained in:
@@ -45,6 +45,7 @@ dependencies {
|
|||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
implementation(libs.compose.activity)
|
implementation(libs.compose.activity)
|
||||||
|
implementation(libs.core.ktx)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
implementation(libs.room.runtime)
|
implementation(libs.room.runtime)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import xyz.cottongin.radio247.data.db.RadioDatabase
|
import xyz.cottongin.radio247.data.db.RadioDatabase
|
||||||
import xyz.cottongin.radio247.data.prefs.RadioPreferences
|
import xyz.cottongin.radio247.data.prefs.RadioPreferences
|
||||||
|
import xyz.cottongin.radio247.service.RadioController
|
||||||
|
|
||||||
class RadioApplication : Application() {
|
class RadioApplication : Application() {
|
||||||
val database: RadioDatabase by lazy {
|
val database: RadioDatabase by lazy {
|
||||||
@@ -14,4 +15,8 @@ class RadioApplication : Application() {
|
|||||||
val preferences: RadioPreferences by lazy {
|
val preferences: RadioPreferences by lazy {
|
||||||
RadioPreferences(this)
|
RadioPreferences(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val controller: RadioController by lazy {
|
||||||
|
RadioController(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,398 @@
|
|||||||
package xyz.cottongin.radio247.service
|
package xyz.cottongin.radio247.service
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.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
|
||||||
|
|
||||||
/**
|
class RadioPlaybackService : LifecycleService() {
|
||||||
* Stub for the media playback foreground service.
|
companion object {
|
||||||
* Will be implemented in a later task.
|
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
||||||
*/
|
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
|
||||||
class RadioPlaybackService : Service() {
|
const val EXTRA_STATION_ID = "station_id"
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.10.1" }
|
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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
room-ktx = { group = "androidx.room", name = "room-ktx", 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" }
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|||||||
Reference in New Issue
Block a user