CarSessionMediaBridge.kt
124 lines · 5.3 KB
Changed in: 0.5.0
| 1 | package dev.stave.cartemplate |
| 2 | |
| 3 | import android.content.ComponentName |
| 4 | import android.content.Context |
| 5 | import androidx.media3.common.MediaItem |
| 6 | import androidx.media3.common.Player |
| 7 | import androidx.media3.session.MediaController |
| 8 | import androidx.media3.session.SessionToken |
| 9 | import com.google.common.util.concurrent.MoreExecutors |
| 10 | import kotlinx.coroutines.flow.MutableStateFlow |
| 11 | import kotlinx.coroutines.flow.StateFlow |
| 12 | import kotlinx.coroutines.flow.asStateFlow |
| 13 | |
| 14 | /** |
| 15 | * Bridges the Car App Library [Session] lifecycle to the existing |
| 16 | * [dev.stave.playback.PlaybackService] MediaSession. |
| 17 | * |
| 18 | * The Car App Library does NOT handle audio playback — it only renders the |
| 19 | * car-projected UI. Playback continues to flow through PlaybackService's |
| 20 | * MediaSession exactly as it does for the standard Auto MediaLibraryService |
| 21 | * path. This bridge connects via [MediaController] so the cartemplate |
| 22 | * screens can: |
| 23 | * - Read currently-playing track metadata (title, artist, album, art) |
| 24 | * - Send transport commands (play, pause, next, previous, seek) |
| 25 | * - Set the playlist (replacing the queue with album-context tracks) |
| 26 | * |
| 27 | * No duplicate playback engine. No duplicate queue. The MediaController and |
| 28 | * the existing MediaSession share state — same way Auto's standard browse |
| 29 | * UI talks to PlaybackService today. |
| 30 | * |
| 31 | * Lifecycle: created when [EarhornCarSession] is constructed, released |
| 32 | * when the Session is destroyed. Connection is async; [isConnected] |
| 33 | * flips to true once the controller has handshaken with the service. |
| 34 | * |
| 35 | * Thread model: callbacks are invoked on the application main thread. |
| 36 | * Both reads of [currentItem] / [playbackState] and writes through the |
| 37 | * controller (play/pause/skip) must happen on the main thread. |
| 38 | */ |
| 39 | class CarSessionMediaBridge(private val context: Context) { |
| 40 | |
| 41 | private var controller: MediaController? = null |
| 42 | |
| 43 | private val _isConnected = MutableStateFlow(false) |
| 44 | val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() |
| 45 | |
| 46 | private val _currentItem = MutableStateFlow<MediaItem?>(null) |
| 47 | /** The currently-playing MediaItem, or null when nothing is loaded. |
| 48 | * Updated whenever the underlying session reports a transition. */ |
| 49 | val currentItem: StateFlow<MediaItem?> = _currentItem.asStateFlow() |
| 50 | |
| 51 | private val _isPlaying = MutableStateFlow(false) |
| 52 | val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow() |
| 53 | |
| 54 | /** Listener that mirrors session state into the bridge's StateFlows. |
| 55 | * Re-registered each time the controller is built. */ |
| 56 | private val playerListener = object : Player.Listener { |
| 57 | override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { |
| 58 | _currentItem.value = mediaItem |
| 59 | } |
| 60 | override fun onIsPlayingChanged(isPlaying: Boolean) { |
| 61 | _isPlaying.value = isPlaying |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /** Build the MediaController and attach the listener. The future |
| 66 | * resolves on the main thread once connection completes; we mark |
| 67 | * [isConnected] = true and prime the StateFlows with the current |
| 68 | * session state so screens that recompose immediately see the |
| 69 | * current track instead of a transient null. |
| 70 | * |
| 71 | * Idempotent — a second connect() while a controller exists is a no-op. */ |
| 72 | fun connect() { |
| 73 | if (controller != null) return |
| 74 | android.util.Log.i("EarhornCar", "Bridge.connect — building MediaController") |
| 75 | val token = SessionToken( |
| 76 | context, |
| 77 | ComponentName(context, dev.stave.playback.PlaybackService::class.java), |
| 78 | ) |
| 79 | val future = MediaController.Builder(context, token).buildAsync() |
| 80 | future.addListener({ |
| 81 | runCatching { |
| 82 | val c = future.get() |
| 83 | controller = c |
| 84 | c.addListener(playerListener) |
| 85 | _currentItem.value = c.currentMediaItem |
| 86 | _isPlaying.value = c.isPlaying |
| 87 | _isConnected.value = true |
| 88 | } |
| 89 | }, MoreExecutors.directExecutor()) |
| 90 | } |
| 91 | |
| 92 | /** Release the controller. Called from [EarhornCarSession.onDestroy]. |
| 93 | * Safe to call multiple times. */ |
| 94 | fun release() { |
| 95 | controller?.let { c -> |
| 96 | c.removeListener(playerListener) |
| 97 | c.release() |
| 98 | } |
| 99 | controller = null |
| 100 | _isConnected.value = false |
| 101 | } |
| 102 | |
| 103 | // ── Transport commands. All are no-ops if not connected. ──────── |
| 104 | |
| 105 | fun play() { controller?.play() } |
| 106 | fun pause() { controller?.pause() } |
| 107 | fun next() { controller?.seekToNextMediaItem() } |
| 108 | fun previous() { controller?.seekToPreviousMediaItem() } |
| 109 | |
| 110 | /** Replace the queue with [items] starting at [startIndex] and play. |
| 111 | * Used by AlbumDetailCarScreen when the user taps a track in the |
| 112 | * album listing — the whole album becomes the queue, not just the |
| 113 | * tapped track. */ |
| 114 | fun playFromList(items: List<MediaItem>, startIndex: Int) { |
| 115 | val c = controller ?: return |
| 116 | c.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), 0L) |
| 117 | c.prepare() |
| 118 | c.play() |
| 119 | } |
| 120 | |
| 121 | /** Snapshot of the current MediaItem for synchronous reads (e.g. the |
| 122 | * tap-on-album-art handler that needs to know which album to open). */ |
| 123 | fun snapshotCurrentItem(): MediaItem? = controller?.currentMediaItem |
| 124 | } |


