CarSessionMediaBridge.kt
Each line, with the release that last changed it.
| 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 | } |


