feat: rewrite RadioPlaybackService as MediaLibraryService with browse tree
Made-with: Cursor
This commit is contained in:
@@ -1,65 +0,0 @@
|
|||||||
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.audio.MetadataFormatter
|
|
||||||
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 != null -> MetadataFormatter.formatTrackInfo(metadata, fallback = "Playing")
|
|
||||||
else -> "Playing"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
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
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.os.IBinder
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LifecycleService
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.session.CommandButton
|
||||||
|
import androidx.media3.session.LibraryResult
|
||||||
|
import androidx.media3.session.MediaLibraryService
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.SessionCommand
|
||||||
|
import androidx.media3.session.SessionResult
|
||||||
|
import com.google.common.collect.ImmutableList
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import xyz.cottongin.radio247.RadioApplication
|
import xyz.cottongin.radio247.RadioApplication
|
||||||
import xyz.cottongin.radio247.audio.AudioEngine
|
import xyz.cottongin.radio247.audio.AudioEngine
|
||||||
import xyz.cottongin.radio247.audio.AudioEngineEvent
|
import xyz.cottongin.radio247.audio.AudioEngineEvent
|
||||||
@@ -25,6 +34,7 @@ import xyz.cottongin.radio247.data.db.StationDao
|
|||||||
import xyz.cottongin.radio247.data.model.ConnectionSpan
|
import xyz.cottongin.radio247.data.model.ConnectionSpan
|
||||||
import xyz.cottongin.radio247.data.model.ListeningSession
|
import xyz.cottongin.radio247.data.model.ListeningSession
|
||||||
import xyz.cottongin.radio247.data.model.MetadataSnapshot
|
import xyz.cottongin.radio247.data.model.MetadataSnapshot
|
||||||
|
import xyz.cottongin.radio247.data.model.Playlist
|
||||||
import xyz.cottongin.radio247.data.model.Station
|
import xyz.cottongin.radio247.data.model.Station
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -42,7 +52,7 @@ import kotlinx.coroutines.Job
|
|||||||
|
|
||||||
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
|
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
|
||||||
|
|
||||||
class RadioPlaybackService : LifecycleService() {
|
class RadioPlaybackService : MediaLibraryService() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "RadioPlayback"
|
private const val TAG = "RadioPlayback"
|
||||||
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
||||||
@@ -64,11 +74,12 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
private val connectionSpanDao: ConnectionSpanDao get() = database.connectionSpanDao()
|
private val connectionSpanDao: ConnectionSpanDao get() = database.connectionSpanDao()
|
||||||
private val metadataSnapshotDao: MetadataSnapshotDao get() = database.metadataSnapshotDao()
|
private val metadataSnapshotDao: MetadataSnapshotDao get() = database.metadataSnapshotDao()
|
||||||
|
|
||||||
private val notificationHelper = NotificationHelper(this)
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val reconnectionMutex = Mutex()
|
private val reconnectionMutex = Mutex()
|
||||||
|
|
||||||
private var mediaSession: MediaSessionCompat? = null
|
private var mediaSession: MediaLibraryService.MediaLibrarySession? = null
|
||||||
|
private var playerAdapter: RadioPlayerAdapter? = null
|
||||||
|
private val seekToLiveCommand = SessionCommand("SEEK_TO_LIVE", Bundle.EMPTY)
|
||||||
private var engine: AudioEngine? = null
|
private var engine: AudioEngine? = null
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
|
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
|
||||||
@@ -101,12 +112,16 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
controller.updateState(newState)
|
controller.updateState(newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibraryService.MediaLibrarySession? {
|
||||||
|
return mediaSession
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationHelper.createChannel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_PLAY -> {
|
ACTION_PLAY -> {
|
||||||
val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L)
|
val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L)
|
||||||
@@ -150,8 +165,6 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? = null
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
cleanupResources()
|
cleanupResources()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
@@ -164,6 +177,8 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
releaseLocks()
|
releaseLocks()
|
||||||
mediaSession?.release()
|
mediaSession?.release()
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
|
playerAdapter?.clearState()
|
||||||
|
playerAdapter = null
|
||||||
unregisterNetworkCallback()
|
unregisterNetworkCallback()
|
||||||
controller.updateLatency(0)
|
controller.updateLatency(0)
|
||||||
}
|
}
|
||||||
@@ -181,7 +196,8 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
|
|
||||||
acquireLocks()
|
acquireLocks()
|
||||||
ensureMediaSession()
|
ensureMediaSession()
|
||||||
startForegroundWithPlaceholder(station)
|
playerAdapter?.updateStation(station)
|
||||||
|
playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val urls = app.streamResolver.resolveUrls(station)
|
val urls = app.streamResolver.resolveUrls(station)
|
||||||
@@ -200,15 +216,15 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
val currentState = controller.state.value
|
val currentState = controller.state.value
|
||||||
when {
|
when {
|
||||||
currentState is PlaybackState.Paused -> {
|
currentState is PlaybackState.Paused -> {
|
||||||
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
|
playerAdapter?.updatePlaybackState(PlaybackState.Paused(station = station, metadata = currentMetadata, sessionStartedAt = sessionStartedAt))
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val isActiveJob = playJob == coroutineContext[Job]
|
val isActiveJob = playJob == coroutineContext[Job]
|
||||||
if (isActiveJob) {
|
if (isActiveJob) {
|
||||||
transition(PlaybackState.Idle)
|
transition(PlaybackState.Idle)
|
||||||
|
playerAdapter?.updatePlaybackState(PlaybackState.Idle)
|
||||||
endListeningSession()
|
endListeningSession()
|
||||||
cleanupResources()
|
cleanupResources()
|
||||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,20 +272,199 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureMediaSession() {
|
private fun ensureMediaSession() {
|
||||||
if (mediaSession == null) {
|
if (mediaSession != null) return
|
||||||
mediaSession = MediaSessionCompat(this, "Radio247").apply {
|
|
||||||
setFlags(
|
val adapter = RadioPlayerAdapter(
|
||||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
onPlay = {
|
||||||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
val state = controller.state.value
|
||||||
|
val station = when (state) {
|
||||||
|
is PlaybackState.Playing -> state.station
|
||||||
|
is PlaybackState.Paused -> state.station
|
||||||
|
is PlaybackState.Connecting -> state.station
|
||||||
|
is PlaybackState.Reconnecting -> state.station
|
||||||
|
is PlaybackState.Idle -> null
|
||||||
|
}
|
||||||
|
station?.let { controller.play(it) }
|
||||||
|
},
|
||||||
|
onStop = { controller.stop() }
|
||||||
|
)
|
||||||
|
playerAdapter = adapter
|
||||||
|
|
||||||
|
val sessionActivityIntent = packageManager
|
||||||
|
.getLaunchIntentForPackage(packageName)
|
||||||
|
?.let { intent ->
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
setCallback(object : MediaSessionCompat.Callback() {
|
|
||||||
override fun onStop() {
|
|
||||||
this@RadioPlaybackService.controller.stop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
isActive = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val seekToLiveButton = CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD)
|
||||||
|
.setDisplayName("Live")
|
||||||
|
.setSessionCommand(seekToLiveCommand)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mediaSession = MediaLibrarySession.Builder(this, adapter, LibrarySessionCallback())
|
||||||
|
.also { builder ->
|
||||||
|
sessionActivityIntent?.let { builder.setSessionActivity(it) }
|
||||||
|
}
|
||||||
|
.setCustomLayout(listOf(seekToLiveButton))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class LibrarySessionCallback : MediaLibraryService.MediaLibrarySession.Callback {
|
||||||
|
|
||||||
|
override fun onGetLibraryRoot(
|
||||||
|
session: MediaLibraryService.MediaLibrarySession,
|
||||||
|
browser: MediaSession.ControllerInfo,
|
||||||
|
params: MediaLibraryService.LibraryParams?
|
||||||
|
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||||
|
val root = MediaItem.Builder()
|
||||||
|
.setMediaId("root")
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setIsBrowsable(true)
|
||||||
|
.setIsPlayable(false)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
return Futures.immediateFuture(LibraryResult.ofItem(root, params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onGetChildren(
|
||||||
|
session: MediaLibraryService.MediaLibrarySession,
|
||||||
|
browser: MediaSession.ControllerInfo,
|
||||||
|
parentId: String,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
params: MediaLibraryService.LibraryParams?
|
||||||
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
|
val future = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
|
||||||
|
serviceScope.launch {
|
||||||
|
try {
|
||||||
|
val items = when {
|
||||||
|
parentId == "root" -> buildRootChildren()
|
||||||
|
parentId == "unsorted" -> buildUnsortedStations()
|
||||||
|
parentId.startsWith("playlist:") -> {
|
||||||
|
val playlistId = parentId.removePrefix("playlist:").toLong()
|
||||||
|
buildPlaylistStations(playlistId)
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return future
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCustomCommand(
|
||||||
|
session: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
customCommand: SessionCommand,
|
||||||
|
args: Bundle
|
||||||
|
): ListenableFuture<SessionResult> {
|
||||||
|
if (customCommand.customAction == "SEEK_TO_LIVE") {
|
||||||
|
handleSeekLive()
|
||||||
|
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||||
|
}
|
||||||
|
return super.onCustomCommand(session, controller, customCommand, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnect(
|
||||||
|
session: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo
|
||||||
|
): MediaSession.ConnectionResult {
|
||||||
|
val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
|
||||||
|
.buildUpon()
|
||||||
|
.add(seekToLiveCommand)
|
||||||
|
.build()
|
||||||
|
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||||
|
.setAvailableSessionCommands(sessionCommands)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAddMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItems: MutableList<MediaItem>
|
||||||
|
): ListenableFuture<List<MediaItem>> {
|
||||||
|
val item = mediaItems.firstOrNull()
|
||||||
|
?: return Futures.immediateFuture(emptyList())
|
||||||
|
val mediaId = item.mediaId
|
||||||
|
if (mediaId.startsWith("station:")) {
|
||||||
|
val stationId = mediaId.removePrefix("station:").toLongOrNull()
|
||||||
|
if (stationId != null) {
|
||||||
|
serviceScope.launch {
|
||||||
|
val station = stationDao.getStationById(stationId)
|
||||||
|
station?.let { this@RadioPlaybackService.controller.play(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Futures.immediateFuture(mediaItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildRootChildren(): List<MediaItem> {
|
||||||
|
val items = mutableListOf<MediaItem>()
|
||||||
|
val playlists: List<Playlist> = database.playlistDao().getAllPlaylists().first()
|
||||||
|
for (playlist in playlists) {
|
||||||
|
items.add(
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setMediaId("playlist:${playlist.id}")
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(playlist.name)
|
||||||
|
.setIsBrowsable(true)
|
||||||
|
.setIsPlayable(false)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val unsorted = stationDao.getUnsortedStations().first()
|
||||||
|
if (unsorted.isNotEmpty()) {
|
||||||
|
items.add(
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setMediaId("unsorted")
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle("Unsorted")
|
||||||
|
.setIsBrowsable(true)
|
||||||
|
.setIsPlayable(false)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildPlaylistStations(playlistId: Long): List<MediaItem> {
|
||||||
|
return stationDao.getStationsByPlaylist(playlistId).first().map { it.toMediaItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildUnsortedStations(): List<MediaItem> {
|
||||||
|
return stationDao.getUnsortedStations().first().map { it.toMediaItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Station.toMediaItem(): MediaItem {
|
||||||
|
return MediaItem.Builder()
|
||||||
|
.setMediaId("station:${id}")
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(name)
|
||||||
|
.setArtworkUri(defaultArtworkUrl?.let { android.net.Uri.parse(it) })
|
||||||
|
.setIsPlayable(true)
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun startEngine(station: Station, urls: List<String>) {
|
private suspend fun startEngine(station: Station, urls: List<String>) {
|
||||||
@@ -284,7 +479,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
sessionStartedAt = sessionStartedAt
|
sessionStartedAt = sessionStartedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
updateNotification(station, null, isReconnecting = false)
|
playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station = station))
|
||||||
|
|
||||||
val bufferMs = app.preferences.bufferMs.first()
|
val bufferMs = app.preferences.bufferMs.first()
|
||||||
engine = AudioEngine(url, bufferMs)
|
engine = AudioEngine(url, bufferMs)
|
||||||
@@ -328,7 +523,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
connectionStartedAt = connectionStartedAt
|
connectionStartedAt = connectionStartedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
updateNotification(station, null, false)
|
playerAdapter?.updatePlaybackState(PlaybackState.Playing(station = station))
|
||||||
controller.updateLatency(engine!!.estimatedLatencyMs)
|
controller.updateLatency(engine!!.estimatedLatencyMs)
|
||||||
}
|
}
|
||||||
is AudioEngineEvent.MetadataChanged -> {
|
is AudioEngineEvent.MetadataChanged -> {
|
||||||
@@ -337,7 +532,16 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
if (playingState is PlaybackState.Playing) {
|
if (playingState is PlaybackState.Playing) {
|
||||||
transition(playingState.copy(metadata = event.metadata))
|
transition(playingState.copy(metadata = event.metadata))
|
||||||
}
|
}
|
||||||
updateNotification(station, event.metadata, false)
|
serviceScope.launch {
|
||||||
|
val artUrl = app.albumArtResolver.resolve(
|
||||||
|
artist = event.metadata.artist,
|
||||||
|
title = event.metadata.title,
|
||||||
|
icyStreamUrl = event.metadata.streamUrl,
|
||||||
|
stationArtworkUrl = station.defaultArtworkUrl
|
||||||
|
)
|
||||||
|
val artUri = artUrl?.let { android.net.Uri.parse(it) }
|
||||||
|
playerAdapter?.updateMetadata(station, event.metadata, artUri)
|
||||||
|
}
|
||||||
persistMetadataSnapshot(station.id, event.metadata)
|
persistMetadataSnapshot(station.id, event.metadata)
|
||||||
}
|
}
|
||||||
is AudioEngineEvent.StreamInfoReceived -> {
|
is AudioEngineEvent.StreamInfoReceived -> {
|
||||||
@@ -395,7 +599,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
attempt = attempt
|
attempt = attempt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
updateNotification(station, currentMetadata, isReconnecting = true)
|
playerAdapter?.updatePlaybackState(PlaybackState.Reconnecting(station = station, metadata = currentMetadata, sessionStartedAt = sessionStartedAt, attempt = attempt))
|
||||||
val delayMs = minOf(1000L * (1 shl (attempt - 1)), 30_000L)
|
val delayMs = minOf(1000L * (1 shl (attempt - 1)), 30_000L)
|
||||||
val chunk = 500L
|
val chunk = 500L
|
||||||
var remaining = delayMs
|
var remaining = delayMs
|
||||||
@@ -439,29 +643,6 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundWithPlaceholder(station: Station) {
|
|
||||||
updateNotification(station, null, isReconnecting = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNotification(station: Station, metadata: IcyMetadata?, isReconnecting: Boolean, isPaused: Boolean = false) {
|
|
||||||
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) {
|
private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -1,100 +1,74 @@
|
|||||||
package xyz.cottongin.radio247.service
|
package xyz.cottongin.radio247.service
|
||||||
|
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.media3.common.BasePlayer
|
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.PlaybackParameters
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.SimpleBasePlayer
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import xyz.cottongin.radio247.audio.IcyMetadata
|
import xyz.cottongin.radio247.audio.IcyMetadata
|
||||||
import xyz.cottongin.radio247.data.model.Station
|
import xyz.cottongin.radio247.data.model.Station
|
||||||
|
|
||||||
class RadioPlayerAdapter(
|
class RadioPlayerAdapter(
|
||||||
private val onPlay: () -> Unit,
|
private val onPlay: () -> Unit,
|
||||||
private val onStop: () -> Unit,
|
private val onStop: () -> Unit,
|
||||||
) : BasePlayer() {
|
) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||||
|
|
||||||
private val listeners = mutableListOf<Player.Listener>()
|
@Volatile
|
||||||
private var _playbackState: Int = Player.STATE_IDLE
|
private var _playbackState: Int = Player.STATE_IDLE
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var _playWhenReady: Boolean = false
|
private var _playWhenReady: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var _mediaMetadata: MediaMetadata = MediaMetadata.EMPTY
|
private var _mediaMetadata: MediaMetadata = MediaMetadata.EMPTY
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var _currentMediaItem: MediaItem? = null
|
private var _currentMediaItem: MediaItem? = null
|
||||||
|
|
||||||
override fun getApplicationLooper(): Looper = Looper.getMainLooper()
|
private val commands = Player.Commands.Builder()
|
||||||
|
.add(Player.COMMAND_PLAY_PAUSE)
|
||||||
|
.add(Player.COMMAND_STOP)
|
||||||
|
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
||||||
|
.add(Player.COMMAND_GET_MEDIA_ITEMS_METADATA)
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun addListener(listener: Player.Listener) {
|
override fun getState(): SimpleBasePlayer.State {
|
||||||
listeners.add(listener)
|
val mediaItem = _currentMediaItem
|
||||||
}
|
val playlist = if (mediaItem != null) {
|
||||||
|
listOf(getPlaceholderMediaItemData(mediaItem))
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun removeListener(listener: Player.Listener) {
|
return SimpleBasePlayer.State.Builder()
|
||||||
listeners.remove(listener)
|
.setAvailableCommands(commands)
|
||||||
}
|
.setPlaybackState(_playbackState)
|
||||||
|
.setPlayWhenReady(_playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||||
override fun getAvailableCommands(): Player.Commands =
|
.setCurrentMediaItemIndex(if (mediaItem != null) 0 else 0)
|
||||||
Player.Commands.Builder()
|
.setContentPositionMs(0L)
|
||||||
.add(Player.COMMAND_PLAY_PAUSE)
|
.setIsLoading(false)
|
||||||
.add(Player.COMMAND_STOP)
|
.setPlaylist(playlist)
|
||||||
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
|
||||||
.add(Player.COMMAND_GET_MEDIA_ITEMS_METADATA)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun getPlaybackState(): Int = _playbackState
|
|
||||||
override fun getPlayWhenReady(): Boolean = _playWhenReady
|
|
||||||
override fun isPlaying(): Boolean = _playbackState == Player.STATE_READY && _playWhenReady
|
|
||||||
override fun getMediaMetadata(): MediaMetadata = _mediaMetadata
|
|
||||||
override fun getCurrentMediaItem(): MediaItem? = _currentMediaItem
|
|
||||||
|
|
||||||
override fun play() {
|
|
||||||
_playWhenReady = true
|
|
||||||
onPlay()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||||
|
_playWhenReady = playWhenReady
|
||||||
|
if (playWhenReady) {
|
||||||
|
onPlay()
|
||||||
|
} else {
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleStop(): ListenableFuture<*> {
|
||||||
_playWhenReady = false
|
_playWhenReady = false
|
||||||
onStop()
|
onStop()
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
_playWhenReady = false
|
|
||||||
onStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prepare() {}
|
|
||||||
|
|
||||||
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
|
||||||
if (playWhenReady) play() else pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun release() {
|
|
||||||
listeners.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
|
|
||||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
|
|
||||||
|
|
||||||
override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF
|
|
||||||
override fun setRepeatMode(repeatMode: Int) {}
|
|
||||||
|
|
||||||
override fun getShuffleModeEnabled(): Boolean = false
|
|
||||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
|
|
||||||
|
|
||||||
override fun seekToDefaultPosition() {}
|
|
||||||
override fun seekToDefaultPosition(mediaItemIndex: Int) {}
|
|
||||||
override fun seekTo(positionMs: Long) {}
|
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {}
|
|
||||||
|
|
||||||
override fun getCurrentTimeline(): Timeline = Timeline.EMPTY
|
|
||||||
override fun getCurrentMediaItemIndex(): Int = 0
|
|
||||||
override fun getContentPosition(): Long = 0
|
|
||||||
override fun getContentDuration(): Long = C.TIME_UNSET
|
|
||||||
override fun getCurrentPosition(): Long = 0
|
|
||||||
override fun getDuration(): Long = C.TIME_UNSET
|
|
||||||
override fun getBufferedPosition(): Long = 0
|
|
||||||
override fun getTotalBufferedDuration(): Long = 0
|
|
||||||
override fun isCurrentMediaItemLive(): Boolean = true
|
|
||||||
|
|
||||||
fun updatePlaybackState(state: PlaybackState) {
|
fun updatePlaybackState(state: PlaybackState) {
|
||||||
val (newPlayerState, newPlayWhenReady) = when (state) {
|
val (newPlayerState, newPlayWhenReady) = when (state) {
|
||||||
is PlaybackState.Idle -> Player.STATE_IDLE to false
|
is PlaybackState.Idle -> Player.STATE_IDLE to false
|
||||||
@@ -103,26 +77,9 @@ class RadioPlayerAdapter(
|
|||||||
is PlaybackState.Paused -> Player.STATE_READY to false
|
is PlaybackState.Paused -> Player.STATE_READY to false
|
||||||
is PlaybackState.Reconnecting -> Player.STATE_BUFFERING to true
|
is PlaybackState.Reconnecting -> Player.STATE_BUFFERING to true
|
||||||
}
|
}
|
||||||
|
|
||||||
val stateChanged = newPlayerState != _playbackState
|
|
||||||
val playWhenReadyChanged = newPlayWhenReady != _playWhenReady
|
|
||||||
_playbackState = newPlayerState
|
_playbackState = newPlayerState
|
||||||
_playWhenReady = newPlayWhenReady
|
_playWhenReady = newPlayWhenReady
|
||||||
|
invalidateState()
|
||||||
if (stateChanged || playWhenReadyChanged) {
|
|
||||||
listeners.forEach { listener ->
|
|
||||||
if (playWhenReadyChanged) {
|
|
||||||
listener.onPlayWhenReadyChanged(
|
|
||||||
newPlayWhenReady,
|
|
||||||
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (stateChanged) {
|
|
||||||
listener.onPlaybackStateChanged(newPlayerState)
|
|
||||||
}
|
|
||||||
listener.onIsPlayingChanged(isPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateStation(station: Station) {
|
fun updateStation(station: Station) {
|
||||||
@@ -135,12 +92,7 @@ class RadioPlayerAdapter(
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
listeners.forEach {
|
invalidateState()
|
||||||
it.onMediaItemTransition(
|
|
||||||
_currentMediaItem,
|
|
||||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMetadata(
|
fun updateMetadata(
|
||||||
@@ -155,6 +107,13 @@ class RadioPlayerAdapter(
|
|||||||
.setArtworkUri(artworkUri)
|
.setArtworkUri(artworkUri)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(true)
|
||||||
.build()
|
.build()
|
||||||
listeners.forEach { it.onMediaMetadataChanged(_mediaMetadata) }
|
invalidateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearState() {
|
||||||
|
_currentMediaItem = null
|
||||||
|
_mediaMetadata = MediaMetadata.EMPTY
|
||||||
|
_playbackState = Player.STATE_IDLE
|
||||||
|
_playWhenReady = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user