feat: rewrite RadioPlaybackService as MediaLibraryService with browse tree

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-18 06:10:17 -04:00
parent 04225de888
commit 00f4da20b6
3 changed files with 286 additions and 211 deletions

View File

@@ -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()
}
}

View File

@@ -1,18 +1,27 @@
package xyz.cottongin.radio247.service
import android.app.PendingIntent
import android.app.Service
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.Bundle
import android.os.PowerManager
import android.util.Log
import androidx.lifecycle.LifecycleService
import android.support.v4.media.session.MediaSessionCompat
import com.google.common.util.concurrent.SettableFuture
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.audio.AudioEngine
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.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -42,7 +52,7 @@ import kotlinx.coroutines.Job
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
class RadioPlaybackService : LifecycleService() {
class RadioPlaybackService : MediaLibraryService() {
companion object {
private const val TAG = "RadioPlayback"
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
@@ -64,11 +74,12 @@ class RadioPlaybackService : LifecycleService() {
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 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 wakeLock: PowerManager.WakeLock? = null
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
@@ -101,12 +112,16 @@ class RadioPlaybackService : LifecycleService() {
controller.updateState(newState)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibraryService.MediaLibrarySession? {
return mediaSession
}
override fun onCreate() {
super.onCreate()
notificationHelper.createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_PLAY -> {
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() {
cleanupResources()
serviceScope.cancel()
@@ -164,6 +177,8 @@ class RadioPlaybackService : LifecycleService() {
releaseLocks()
mediaSession?.release()
mediaSession = null
playerAdapter?.clearState()
playerAdapter = null
unregisterNetworkCallback()
controller.updateLatency(0)
}
@@ -181,7 +196,8 @@ class RadioPlaybackService : LifecycleService() {
acquireLocks()
ensureMediaSession()
startForegroundWithPlaceholder(station)
playerAdapter?.updateStation(station)
playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station))
try {
val urls = app.streamResolver.resolveUrls(station)
@@ -200,15 +216,15 @@ class RadioPlaybackService : LifecycleService() {
val currentState = controller.state.value
when {
currentState is PlaybackState.Paused -> {
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
playerAdapter?.updatePlaybackState(PlaybackState.Paused(station = station, metadata = currentMetadata, sessionStartedAt = sessionStartedAt))
}
else -> {
val isActiveJob = playJob == coroutineContext[Job]
if (isActiveJob) {
transition(PlaybackState.Idle)
playerAdapter?.updatePlaybackState(PlaybackState.Idle)
endListeningSession()
cleanupResources()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
@@ -256,20 +272,199 @@ class RadioPlaybackService : LifecycleService() {
}
private fun ensureMediaSession() {
if (mediaSession == null) {
mediaSession = MediaSessionCompat(this, "Radio247").apply {
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
if (mediaSession != null) return
val adapter = RadioPlayerAdapter(
onPlay = {
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>) {
@@ -284,7 +479,7 @@ class RadioPlaybackService : LifecycleService() {
sessionStartedAt = sessionStartedAt
)
)
updateNotification(station, null, isReconnecting = false)
playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station = station))
val bufferMs = app.preferences.bufferMs.first()
engine = AudioEngine(url, bufferMs)
@@ -328,7 +523,7 @@ class RadioPlaybackService : LifecycleService() {
connectionStartedAt = connectionStartedAt
)
)
updateNotification(station, null, false)
playerAdapter?.updatePlaybackState(PlaybackState.Playing(station = station))
controller.updateLatency(engine!!.estimatedLatencyMs)
}
is AudioEngineEvent.MetadataChanged -> {
@@ -337,7 +532,16 @@ class RadioPlaybackService : LifecycleService() {
if (playingState is PlaybackState.Playing) {
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)
}
is AudioEngineEvent.StreamInfoReceived -> {
@@ -395,7 +599,7 @@ class RadioPlaybackService : LifecycleService() {
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 chunk = 500L
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) {
val now = System.currentTimeMillis()
withContext(Dispatchers.IO) {

View File

@@ -1,100 +1,74 @@
package xyz.cottongin.radio247.service
import android.os.Looper
import androidx.media3.common.BasePlayer
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
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.data.model.Station
class RadioPlayerAdapter(
private val onPlay: () -> Unit,
private val onStop: () -> Unit,
) : BasePlayer() {
) : SimpleBasePlayer(Looper.getMainLooper()) {
private val listeners = mutableListOf<Player.Listener>()
@Volatile
private var _playbackState: Int = Player.STATE_IDLE
@Volatile
private var _playWhenReady: Boolean = false
@Volatile
private var _mediaMetadata: MediaMetadata = MediaMetadata.EMPTY
@Volatile
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) {
listeners.add(listener)
}
override fun getState(): SimpleBasePlayer.State {
val mediaItem = _currentMediaItem
val playlist = if (mediaItem != null) {
listOf(getPlaceholderMediaItemData(mediaItem))
} else {
emptyList()
}
override fun removeListener(listener: Player.Listener) {
listeners.remove(listener)
}
override fun getAvailableCommands(): Player.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)
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands)
.setPlaybackState(_playbackState)
.setPlayWhenReady(_playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.setCurrentMediaItemIndex(if (mediaItem != null) 0 else 0)
.setContentPositionMs(0L)
.setIsLoading(false)
.setPlaylist(playlist)
.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
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) {
val (newPlayerState, newPlayWhenReady) = when (state) {
is PlaybackState.Idle -> Player.STATE_IDLE to false
@@ -103,26 +77,9 @@ class RadioPlayerAdapter(
is PlaybackState.Paused -> Player.STATE_READY to false
is PlaybackState.Reconnecting -> Player.STATE_BUFFERING to true
}
val stateChanged = newPlayerState != _playbackState
val playWhenReadyChanged = newPlayWhenReady != _playWhenReady
_playbackState = newPlayerState
_playWhenReady = newPlayWhenReady
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)
}
}
invalidateState()
}
fun updateStation(station: Station) {
@@ -135,12 +92,7 @@ class RadioPlayerAdapter(
.build()
)
.build()
listeners.forEach {
it.onMediaItemTransition(
_currentMediaItem,
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
)
}
invalidateState()
}
fun updateMetadata(
@@ -155,6 +107,13 @@ class RadioPlayerAdapter(
.setArtworkUri(artworkUri)
.setIsPlayable(true)
.build()
listeners.forEach { it.onMediaMetadataChanged(_mediaMetadata) }
invalidateState()
}
fun clearState() {
_currentMediaItem = null
_mediaMetadata = MediaMetadata.EMPTY
_playbackState = Player.STATE_IDLE
_playWhenReady = false
}
}