feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
2 changed files with 182 additions and 0 deletions
Showing only changes of commit 4fde634074 - Show all commits
@@ -0,0 +1,161 @@
package com.fabledsword.minstrel.player
import android.content.ComponentName
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* UI-facing facade over Media3's [MediaController] — the IPC client to
* [MinstrelPlayerService]'s [androidx.media3.session.MediaSession].
*
* ViewModels collect [uiState] (a StateFlow projection of the player's
* current track + transport state + position) and call the
* transport methods. The MediaController is connected asynchronously in
* the singleton's init block; until it's ready, transport methods are
* no-ops and uiState carries the default (empty) snapshot.
*
* Why a dedicated facade vs ViewModels using MediaController directly:
* lifecycle. ViewModels come and go with their host Composables; one
* MediaController for the process is the right shape, and centralising
* the StateFlow projection means each ViewModel doesn't have to attach
* its own Player.Listener.
*/
@Singleton
class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
) {
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
private var mediaController: MediaController? = null
private val uiStateInternal = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = uiStateInternal.asStateFlow()
/**
* Stable queue snapshot kept in sync with the player's MediaItems —
* the player's own getMediaItem(index) returns Media3 types; we keep
* our domain TrackRef list alongside for the UiState projection.
*/
private var queueRefs: List<TrackRef> = emptyList()
init {
scope.launch { connectAndObserve() }
}
// ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() }
fun pause() { mediaController?.pause() }
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
fun skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
/**
* Replace the queue with [tracks] and start playing from [initialIndex].
* [source] tags the queue with its origin (e.g. "for_you") so the
* server-side rotation reporter can advance it; carried in
* MediaItem extras.
*/
fun setQueue(tracks: List<TrackRef>, initialIndex: Int = 0, source: String? = null) {
val controller = mediaController ?: return
queueRefs = tracks
val items = tracks.map { it.toMediaItem(source) }
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
controller.play()
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
private suspend fun connectAndObserve() {
val controller =
try {
connectController()
} catch (e: Exception) {
uiStateInternal.value =
uiStateInternal.value.copy(playbackError = e.message ?: "Player unavailable")
return
}
mediaController = controller
controller.addListener(
object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
val idx = player.currentMediaItemIndex
val current = queueRefs.getOrNull(idx)
uiStateInternal.value =
PlayerUiState(
currentTrack = current,
queue = queueRefs,
queueIndex = idx,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message,
)
}
},
)
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`
* — the future's `addListener` callback is enough. `Runnable::run`
* is a direct executor (no extra threading) since the callback only
* unparks our continuation.
*/
private suspend fun connectController(): MediaController =
suspendCancellableCoroutine { cont ->
val future = MediaController.Builder(context, sessionToken).buildAsync()
future.addListener(
{
try {
cont.resume(future.get())
} catch (e: Exception) {
cont.resumeWithException(e)
}
},
Runnable::run,
)
cont.invokeOnCancellation { future.cancel(/* mayInterruptIfRunning = */ false) }
}
private fun TrackRef.toMediaItem(source: String?): MediaItem =
MediaItem.Builder()
.setMediaId(id)
.setUri(streamUrl)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artistName)
.setAlbumTitle(albumTitle)
.apply { source?.let { setExtras(android.os.Bundle().apply { putString(MINSTREL_SOURCE_KEY, it) }) } }
.build(),
)
.build()
companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
}
}
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef
/**
* Snapshot of what the player is doing, for the UI to read via
* [PlayerController.uiState]. Replaces the audio_service-style
* `PlaybackState + MediaItem` split — Media3 has those internally;
* this is the projection we hand to ViewModels.
*/
data class PlayerUiState(
val currentTrack: TrackRef? = null,
val queue: List<TrackRef> = emptyList(),
val queueIndex: Int = -1,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val bufferedPositionMs: Long = 0,
val playbackError: String? = null,
)