feat(android): PlayerController + PlayerUiState (M8 phase 6.3)

Hilt-singleton facade over Media3 MediaController (the IPC client to
MinstrelPlayerService's MediaSession). One process-wide controller +
one StateFlow projection means ViewModels don't each attach their
own Player.Listener.

PlayerUiState: data class with currentTrack / queue / queueIndex /
isPlaying / isBuffering / positionMs / durationMs / bufferedPositionMs
/ playbackError. The mini-player + NowPlayingScreen (Phase 6.4) read
this; the rest of the app sees one consistent player snapshot.

PlayerController:
  - init: async connectAndObserve via suspendCancellableCoroutine
    bridging Media3's ListenableFuture<MediaController>.buildAsync().
    Skips the kotlinx-coroutines-guava dep (Runnable::run is a direct
    executor; the listener just unparks our continuation).
  - Transport methods (play/pause/seekTo/skipToNext/skipToPrevious)
    are no-ops until the controller connects; safe to call early.
  - setQueue(tracks, initialIndex, source) — TrackRef -> MediaItem
    with mediaId + uri + metadata; source tag goes in extras for the
    server-side rotation reporter (#415 parity).
  - Player.Listener.onEvents drives uiState snapshot — Media3 batches
    related events so we don't churn the StateFlow per-event.
  - queueRefs kept as our own list so the UiState projection has
    domain TrackRefs (Media3 has MediaItems internally).

No tests yet — PlayerController's main behavior is IPC-mediated and
benefits from an instrumented test (Robolectric or device). JVM
unit tests for it would mostly mock the MediaController and verify
trivial method-forwarding. Deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:06:46 -04:00
parent 85b8452b78
commit 4fde634074
2 changed files with 182 additions and 0 deletions
@@ -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,
)