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