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

EarhornCarSession.kt

package dev.stave.cartemplate

import androidx.car.app.CarContext
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.media.MediaPlaybackManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

/**
 * Car App Library session. One instance per projection connection.
 *
 * Lifecycle:
 *   onCreate  → connect the [CarSessionMediaBridge] to PlaybackService,
 *                register the media playback token so the host can read
 *                playback state for MediaPlaybackTemplate.
 *   onDestroy → release the bridge.
 *
 * The start screen is [NowPlayingCarScreen] which uses
 * [MediaPlaybackTemplate] — the host renders album art, metadata,
 * and transport controls from the MediaSession automatically.
 */
@OptIn(ExperimentalCarApi::class)
class EarhornCarSession : Session() {

    private lateinit var bridge: CarSessionMediaBridge

    override fun onCreateScreen(intent: android.content.Intent): Screen {
        bridge = CarSessionMediaBridge(carContext)

        lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onCreate(owner: LifecycleOwner) {
                android.util.Log.i("EarhornCar", "╠══ CarSession.onCreate — connecting bridge ══╣")
                bridge.connect()

                // Register the MediaSession platform token so the Auto host
                // can read playback state (album art, track info, progress,
                // controls) for MediaPlaybackTemplate. Without this call,
                // the template renders empty.
                // PlaybackService may not have built its session yet on a
                // cold start. One retry after 2 s used to be all there was:
                // a slow service start left the Auto template empty until
                // the car reconnected (review N.91, the maintainer's order
                // 2026-09-02). Retry every 2 s for up to 30 s, and stop when
                // the session is destroyed.
                val handler = android.os.Handler(android.os.Looper.getMainLooper())
                var attempts = 0
                lateinit var tryRegister: Runnable
                tryRegister = Runnable {
                    if (!owner.lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.CREATED)) return@Runnable
                    attempts++
                    val token = dev.stave.playback.PlaybackService.sessionCompatToken
                    if (token != null) {
                        android.util.Log.i("EarhornCar",
                            "╠══ Registering media playback token (attempt $attempts) ══╣")
                        runCatching {
                            val manager = carContext.getCarService(CarContext.MEDIA_PLAYBACK_SERVICE)
                                as MediaPlaybackManager
                            manager.registerMediaPlaybackToken(token)
                        }.onFailure {
                            android.util.Log.e("EarhornCar", "Failed to register playback token", it)
                        }
                    } else if (attempts < 15) {
                        android.util.Log.w("EarhornCar",
                            "Platform session token not yet available — retry $attempts/15 in 2 s")
                        handler.postDelayed(tryRegister, 2000)
                    } else {
                        android.util.Log.e("EarhornCar", "Platform session token still null after 30 s — giving up")
                    }
                }
                tryRegister.run()
            }
            override fun onDestroy(owner: LifecycleOwner) {
                android.util.Log.i("EarhornCar", "╠══ CarSession.onDestroy — releasing bridge ══╣")
                bridge.release()
            }
        })

        return NowPlayingCarScreen(carContext, bridge)
    }
}