diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt new file mode 100644 index 00000000..173b0947 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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 = 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 = 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, 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.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" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt new file mode 100644 index 00000000..6d1253b2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -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 = 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, +)