EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

CarSessionMediaBridge.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.cartemplate
2
3import android.content.ComponentName
4import android.content.Context
5import androidx.media3.common.MediaItem
6import androidx.media3.common.Player
7import androidx.media3.session.MediaController
8import androidx.media3.session.SessionToken
9import com.google.common.util.concurrent.MoreExecutors
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import 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 */
39class 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}