android auto: a tap in the car plays, with no Activity anywhere in the process

Android Auto starts this app by binding a service. There is no Activity, no
WebView, no `tauri::AppHandle` — and until now no way to make a sound, because
the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it
into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it,
`playback::init` on a normal launch or a car tapping a row into a process with
no UI, and everyone after that gets the same one.

**That singleton is the whole safety story.** `Player::spawn` opens an output
stream, so two of them in one process is two AAudio streams playing over each
other — the failure this module exists to make impossible. The event receiver
is single-consumer, so the pump lives there too and offers a subscription slot
for the WebView to claim when it finally appears, rather than a second engine
being stood up beside the first.

The queue here is deliberately not the app's queue. The JS one keeps shuffle,
repeat, `alts` failover, provenance and the three-ahead preload; this is a
vector and an index whose only job is that the album keeps playing when there
is no JS alive to advance it. Its metadata is read ONCE, when the queue is
built, so advancing never touches the database again — which matters when the
thing advancing is a background thread in a process with no UI.

Resolution happens in Rust, against the library, read-only. A tapped
`ss:track:<id>` brings its whole album at the right index, because selecting
one song and getting silence after it is not what a record does. Nothing
trusts a URI from the controller: the tree only ever publishes `ss:` ids, so
there is no file path for anyone to hand back, and an id that resolves to
nothing returns a count of zero rather than a guess.

Kotlin's half is three things that each fail invisibly on their own.
`COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn
as playable and does nothing when tapped. `onAddMediaItems` passes ids through
instead of media3's default `UnsupportedOperationException`, which kills the
tap before it reaches the player. And `handleSetMediaItems` does the work on
its own thread, because the first tap in a cold process opens a database AND
starts an audio engine, and it is called on the application thread where ten
seconds is an ANR.

**Two things the device taught, both of which looked like silence.** The
headless path has to request audio focus itself: the first run showed a live
AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain
at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car
tapping a cold process has no JS to ask. And it has to write the card itself:
`getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so
the second run played audio while the session still reported `STATE_NONE` with
no title — a head unit showing a blank player over music. The engine knows what
it started, and that now goes into `MediaHub.update` in the shape the card
already reads.

Proven by the prober, which gained a `play` mode for it: bind cold, issue
`playFromMediaId` through the session token the browser handed back — Auto's
own path, not an interface of ours — and read the state back. `ss:track:1`
answers `queued=13` (the album behind it), and the controller reports
`state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across
the run. `probe.sh <depth> <recent> <mediaId>` is the invocation.

**Not done, and it is the display rather than the audio.** Nothing tells Kotlin
when the fallback queue advances, so the card will keep showing track one while
track two plays. The position moves on its own — `SimpleBasePlayer`
extrapolates it while playing — but a track change and a seek both need the
pump to push, and the pump currently only speaks to a subscriber that exists
when there is a WebView. The handover the design asks for (a UI arriving over
live headless playback, and exactly one queue driver surviving) is untested for
the same reason.
This commit is contained in:
KeelrLeek committed 2026-09-15 11:49:40 -05:00
1 parent 3f56f274c5
commit f50cf2018a
8 files changed
+527 -6

No files matched your search

@@ -567,6 +567,23 @@ object LibraryTree : MediaLibrarySession.Callback {
io,
)
/**
* A controller handing us items that carry a media id and no URI — which
* is every tap on a browse row, because the tree publishes `ss:` ids and
* never a file path.
*
* media3's default answers UnsupportedOperationException here and the tap
* dies before it ever reaches the player. Passing the items straight
* through is the right move for this app: resolution happens in
* [SoulStreamPlayer.handleSetMediaItems], against the library, where an id
* is checked rather than believed.
*/
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> = Futures.immediateFuture(mediaItems)
/**
* How the car should DRAW this node's children — the only say the app has
* over the layout, since the host builds the UI and we supply content.
@@ -979,4 +996,19 @@ object AutoNative {
/** Returns a JSON array of rows; never null, `[]` when anything failed. */
external fun browse(dataDir: String, parentId: String): String
/**
* Start playback from a browse row, in a process that may have no
* Activity. Returns how many tracks were queued — 0 means the row
* resolved to nothing, which the car should be told about rather than
* left in silence.
*
* A tapped track brings its album with it, so the record keeps playing
* without any JS alive to advance it.
*/
external fun play(dataDir: String, mediaId: String): String
/** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */
external fun transport(action: String)
}
@@ -203,10 +203,16 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* then sets the button's command to the _MEDIA_ITEM one: advertise only
* the plain one and the button appears and does nothing.
*
* Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS
* and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android
* Auto's job and needs a window-index → queue-index mapping; `queueBase`
* is half of it and it lands with the round that uses it. */
* COMMAND_SET_MEDIA_ITEM arrived with the round that plays from the car:
* it is what lets a controller hand us a media id at all, and without it
* a browse row is drawn as playable and does nothing when tapped. It is
* safe to advertise unconditionally because [handleSetMediaItems]
* resolves the id against the library itself rather than trusting a URI.
*
* Still not here, on purpose: COMMAND_CHANGE_MEDIA_ITEMS and
* COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the QUEUE is a different
* thing from tapping a row of the library — it needs a window-index →
* queue-index mapping, and `queueBase` is only half of that. */
private fun commands(np: NowPlayingArgs?): Player.Commands {
val b = Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
@@ -220,6 +226,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
.add(Player.COMMAND_GET_METADATA)
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
.add(Player.COMMAND_SET_MEDIA_ITEM)
if (np != null) {
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
@@ -242,6 +249,77 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* `handle*` is `throw IllegalStateException`. */
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
/**
* A row tapped in the car.
*
* The id is resolved against the library IN RUST, not from a URI on the
* item: the browse tree hands out `ss:track:<id>` and nothing else, so
* there is no file path to trust and no way for a controller to name a
* file we did not publish.
*
* It runs off the caller's thread because the first tap in a cold process
* does two slow things — opens the database and starts the audio engine —
* and this is called on the application thread, where ten seconds is an
* ANR. The future resolving is what tells media3 the command took.
*
* `startIndex` can be `INDEX_UNSET`; that means "play the list from the
* top", which for a single tapped row is the row itself.
*/
override fun handleSetMediaItems(
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<*> {
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
val id = mediaItems.getOrNull(i)?.mediaId
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
return Futures.submit<Unit>(
{
// FOCUS FIRST, and it is not optional. Without it the track
// opens into a port the framework keeps muted: the first run
// of this path showed a live AAudio track on our uid with
// PortMuted=true and the gain at -inf — decoding perfectly,
// audible to nobody. On the WebView path the JS asks for
// focus; a car tapping a cold process has no JS to do it.
MediaHub.requestFocus(ctx.applicationContext)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
if (queued == 0) {
// Nothing resolved: give the focus back rather than
// holding another app's audio hostage for silence.
MediaHub.dropFocus(ctx.applicationContext)
} else {
// Write the card ourselves. `getState()` mirrors
// MediaHub.state, and the only thing that ever fills it is
// the WebView — so a cold car tap played audio while the
// session still said STATE_NONE with no title, which is a
// head unit showing a blank player over music. The engine
// knows what it started; this is that, in the shape the
// card already reads.
val np = NowPlayingArgs()
np.title = o.optString("title").ifEmpty { null }
np.artist = o.optString("artist").ifEmpty { null }
np.album = o.optString("album").ifEmpty { null }
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
np.position = 0.0
np.playing = true
np.hasNext = queued > 1
np.hasPrev = false
MediaHub.update(ctx.applicationContext, np)
}
},
playIo,
)
}
/** One thread, for the tap that opens a database and starts an engine. */
private val playIo =
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
Thread(r, "soulstream-auto-play").apply { isDaemon = true }
}
override fun handleStop(): ListenableFuture<*> {
MediaHub.act("stop")
return awaitUpdate()