package dev.stave.cartemplate import android.content.ComponentName import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * Bridges the Car App Library [Session] lifecycle to the existing * [dev.stave.playback.PlaybackService] MediaSession. * * The Car App Library does NOT handle audio playback — it only renders the * car-projected UI. Playback continues to flow through PlaybackService's * MediaSession exactly as it does for the standard Auto MediaLibraryService * path. This bridge connects via [MediaController] so the cartemplate * screens can: * - Read currently-playing track metadata (title, artist, album, art) * - Send transport commands (play, pause, next, previous, seek) * - Set the playlist (replacing the queue with album-context tracks) * * No duplicate playback engine. No duplicate queue. The MediaController and * the existing MediaSession share state — same way Auto's standard browse * UI talks to PlaybackService today. * * Lifecycle: created when [EarhornCarSession] is constructed, released * when the Session is destroyed. Connection is async; [isConnected] * flips to true once the controller has handshaken with the service. * * Thread model: callbacks are invoked on the application main thread. * Both reads of [currentItem] / [playbackState] and writes through the * controller (play/pause/skip) must happen on the main thread. */ class CarSessionMediaBridge(private val context: Context) { private var controller: MediaController? = null private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() private val _currentItem = MutableStateFlow(null) /** The currently-playing MediaItem, or null when nothing is loaded. * Updated whenever the underlying session reports a transition. */ val currentItem: StateFlow = _currentItem.asStateFlow() private val _isPlaying = MutableStateFlow(false) val isPlaying: StateFlow = _isPlaying.asStateFlow() /** Listener that mirrors session state into the bridge's StateFlows. * Re-registered each time the controller is built. */ private val playerListener = object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { _currentItem.value = mediaItem } override fun onIsPlayingChanged(isPlaying: Boolean) { _isPlaying.value = isPlaying } } /** Build the MediaController and attach the listener. The future * resolves on the main thread once connection completes; we mark * [isConnected] = true and prime the StateFlows with the current * session state so screens that recompose immediately see the * current track instead of a transient null. * * Idempotent — a second connect() while a controller exists is a no-op. */ fun connect() { if (controller != null) return android.util.Log.i("EarhornCar", "Bridge.connect — building MediaController") val token = SessionToken( context, ComponentName(context, dev.stave.playback.PlaybackService::class.java), ) val future = MediaController.Builder(context, token).buildAsync() future.addListener({ runCatching { val c = future.get() controller = c c.addListener(playerListener) _currentItem.value = c.currentMediaItem _isPlaying.value = c.isPlaying _isConnected.value = true } }, MoreExecutors.directExecutor()) } /** Release the controller. Called from [EarhornCarSession.onDestroy]. * Safe to call multiple times. */ fun release() { controller?.let { c -> c.removeListener(playerListener) c.release() } controller = null _isConnected.value = false } // ── Transport commands. All are no-ops if not connected. ──────── fun play() { controller?.play() } fun pause() { controller?.pause() } fun next() { controller?.seekToNextMediaItem() } fun previous() { controller?.seekToPreviousMediaItem() } /** Replace the queue with [items] starting at [startIndex] and play. * Used by AlbumDetailCarScreen when the user taps a track in the * album listing — the whole album becomes the queue, not just the * tapped track. */ fun playFromList(items: List, startIndex: Int) { val c = controller ?: return c.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), 0L) c.prepare() c.play() } /** Snapshot of the current MediaItem for synchronous reads (e.g. the * tap-on-album-art handler that needs to know which album to open). */ fun snapshotCurrentItem(): MediaItem? = controller?.currentMediaItem }