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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user