EarhornCarSession.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.cartemplate |
| 2 | ||
| 3 | import androidx.car.app.CarContext | |
| 4 | import androidx.car.app.annotations.ExperimentalCarApi | |
| 5 | import androidx.car.app.Screen | |
| 6 | import androidx.car.app.Session | |
| 7 | import androidx.car.app.media.MediaPlaybackManager | |
| 8 | import androidx.lifecycle.DefaultLifecycleObserver | |
| 9 | import androidx.lifecycle.LifecycleOwner | |
| 10 | ||
| 11 | /** | |
| 12 | * Car App Library session. One instance per projection connection. | |
| 13 | * | |
| 14 | * Lifecycle: | |
| 15 | * onCreate → connect the [CarSessionMediaBridge] to PlaybackService, | |
| 16 | * register the media playback token so the host can read | |
| 17 | * playback state for MediaPlaybackTemplate. | |
| 18 | * onDestroy → release the bridge. | |
| 19 | * | |
| 20 | * The start screen is [NowPlayingCarScreen] which uses | |
| 21 | * [MediaPlaybackTemplate] — the host renders album art, metadata, | |
| 22 | * and transport controls from the MediaSession automatically. | |
| 23 | */ | |
| 24 | @OptIn(ExperimentalCarApi::class) | |
| 25 | class EarhornCarSession : Session() { | |
| 26 | ||
| 27 | private lateinit var bridge: CarSessionMediaBridge | |
| 28 | ||
| 29 | override fun onCreateScreen(intent: android.content.Intent): Screen { | |
| 30 | bridge = CarSessionMediaBridge(carContext) | |
| 31 | ||
| 32 | lifecycle.addObserver(object : DefaultLifecycleObserver { | |
| 33 | override fun onCreate(owner: LifecycleOwner) { | |
| 34 | android.util.Log.i("EarhornCar", "╠══ CarSession.onCreate — connecting bridge ══╣") | |
| 35 | bridge.connect() | |
| 36 | ||
| 37 | // Register the MediaSession platform token so the Auto host | |
| 38 | // can read playback state (album art, track info, progress, | |
| 39 | // controls) for MediaPlaybackTemplate. Without this call, | |
| 40 | // the template renders empty. | |
| 41 | // PlaybackService may not have built its session yet on a | |
| 42 | // cold start. One retry after 2 s used to be all there was: | |
| 43 | // a slow service start left the Auto template empty until | |
| 44 | // the car reconnected (review N.91, the maintainer's order | |
| 45 | // 2026-09-02). Retry every 2 s for up to 30 s, and stop when | |
| 46 | // the session is destroyed. | |
| 47 | val handler = android.os.Handler(android.os.Looper.getMainLooper()) | |
| 48 | var attempts = 0 | |
| 49 | lateinit var tryRegister: Runnable | |
| 50 | tryRegister = Runnable { | |
| 51 | if (!owner.lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.CREATED)) return@Runnable | |
| 52 | attempts++ | |
| 53 | val token = dev.stave.playback.PlaybackService.sessionCompatToken | |
| 54 | if (token != null) { | |
| 55 | android.util.Log.i("EarhornCar", | |
| 56 | "╠══ Registering media playback token (attempt $attempts) ══╣") | |
| 57 | runCatching { | |
| 58 | val manager = carContext.getCarService(CarContext.MEDIA_PLAYBACK_SERVICE) | |
| 59 | as MediaPlaybackManager | |
| 60 | manager.registerMediaPlaybackToken(token) | |
| 61 | }.onFailure { | |
| 62 | android.util.Log.e("EarhornCar", "Failed to register playback token", it) | |
| 63 | } | |
| 64 | } else if (attempts < 15) { | |
| 65 | android.util.Log.w("EarhornCar", | |
| 66 | "Platform session token not yet available — retry $attempts/15 in 2 s") | |
| 67 | handler.postDelayed(tryRegister, 2000) | |
| 68 | } else { | |
| 69 | android.util.Log.e("EarhornCar", "Platform session token still null after 30 s — giving up") | |
| 70 | } | |
| 71 | } | |
| 72 | tryRegister.run() | |
| 73 | } | |
| 74 | override fun onDestroy(owner: LifecycleOwner) { | |
| 75 | android.util.Log.i("EarhornCar", "╠══ CarSession.onDestroy — releasing bridge ══╣") | |
| 76 | bridge.release() | |
| 77 | } | |
| 78 | }) | |
| 79 | ||
| 80 | return NowPlayingCarScreen(carContext, bridge) | |
| 81 | } | |
| 82 | } |


