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

CarSessionMediaBridge.kt

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<Boolean> = _isConnected.asStateFlow()

    private val _currentItem = MutableStateFlow<MediaItem?>(null)
    /** The currently-playing MediaItem, or null when nothing is loaded.
     *  Updated whenever the underlying session reports a transition. */
    val currentItem: StateFlow<MediaItem?> = _currentItem.asStateFlow()

    private val _isPlaying = MutableStateFlow(false)
    val isPlaying: StateFlow<Boolean> = _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<MediaItem>, 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
}