android auto: the car's queue works like the page's, and every row gets its cover

"Needs the app" is gone. It named what the cold service could not do — the
resolver and the Soulseek session live in the page, and a car that starts
the app cold has no page — and Kyle's answer to the label was the right
one: a tap should find a copy and play it, in the background, and a
playlist should keep going through rows that are not here exactly as the
page keeps going.

headless_slsk.rs is a Soulseek session for a process with no page: the
stored account, one search (artist + simplified title, the server's
excluded phrases stripped), copies ranked by rules close to the page's
songCandidates — the title in the filename, streamable formats, a duration
within eight per cent, lossless over 320 over the rest, the artist in the
path, a free upload slot before anything else — and a download into the
same stream cache the page uses. Its own tokio runtime: a car-started
process has no Tauri one.

headless.rs is the page's queue in Rust. resolve_playable queues EVERY row
of a playlist in order, a row not here with its remembered peer file and
no path. The engine starts a row from its file when it is whole, from the
fetch already filling its cache file when one is, and otherwise finds it
on the network right then (the remembered peer first, then the search),
playing the bytes as they land; a peer that sends nothing for fifteen
seconds is dropped for the next copy, twice, and when a row has no copy
anywhere the queue moves on — only the last row's failure reaches the
card. The next three rows are fetched ahead of time, four seconds after a
start so nothing competes with the track that is starting, and when the
immediate next is whole before the current one ends it is appended to the
sink and the boundary is gapless — the page's preloadLocal and
preloadCachedStream in one. Fetches are keyed by cache file, not index, so
a shuffle cannot start the same file from the same peer twice. The card
shows the title at once with a spinner (STATE_BUFFERING through
headlessState), then the track; every source is remembered in the mirror,
so the row is "here" next time.

One session per process: when the app opens over it, login_inner ADOPTS
the client and reads its events through subscribe() instead of logging in
again — two logins from one address in quick succession is what the server
throttles — and the fetches in flight become the page's own preloads, so
the row the car was fetching ahead lands gaplessly under the page too.

Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have
no catalog cover, and the car drew the app icon over them. In the cold
process the cover embedded in the file is extracted — the desktop's own
tagger::extract_art, keyed by the audio path under
<library>/.soulstream/stream-art/by-file/, a miss remembered as an empty
file — for browse rows, the card and the Continue row, and a row fetched
cold gets the same once 200 kB of it have landed. On the phone,
coverart.svelte.js is the desktop's playlist backfill as a background walk
over every playlist: the file before the catalog (the row's own cached
copy, persisted as art_path), then the ONE strict ladder the import review
uses (findCatalogTrack, persisted as art_url + track_id) — never a loose
name lookup. After boot, on every playlist change, every six hours, the
open page's rows first, a miss remembered for a day and only when the
catalog answered. The player does the same for the track that is playing
(ensureStreamArt: embedded, then catalog), makes the URL the entry's art
key and hands it to the walk to persist. The page's own hole is closed
too: a streamed row with no cover got no art key at all, and where it had
one the extractor's FILE PATH was stored where a data URL belongs; such
rows get a "stream:" key and the file is read as a thumbnail, and a stream
row's art_path now draws on the phone.

A car that starts the app cold has no page to walk for it, so the cold
process asks the catalog itself. cover_hunt.rs is catalogMatch.js in
Rust — the same Deezer field queries through the metadata crate, a match
only when the artist agrees, the base title agrees, the version
qualifiers are compatible and a known duration does not contradict; the
two are meant to stay in step — fed by every browse and every queue built
from a playlist. A blank stream row is answered from what was asked
before (stream-art/catalog.json, a miss for a day and only when the
catalog answered), else queued for one thread that looks rows up one at
a time behind the client's own Deezer pacing, writes the answer on the
row (art_url and the track id, only ever filling a NULL; the library is
WAL and the page's writer is another connection), and re-lists the
playlist on the car. The track that is playing gets the same, through an
entry that moves only the card's art and never its position. The cold
Soulseek search is the page's two-rung ladder as well: artist plus the
simplified title, then the title alone with the artist required in the
peer's path; the hunt's last rung is the page's last rung too, the
artist's whole shelf flattened to tracks, for the records the search
index buries — spent only on the track that is playing, bounded to forty
seconds, and the cover memory carries the matcher's version so a "no"
from an older matcher is no answer once a rung is added.

Two things the head unit found in the seam. media3's default onSubscribe
answers "not supported", so nobody was ever subscribed and the re-list
notice reached nobody; LibraryTree accepts subscriptions now, and covers
land on the car's list within seconds of being found. And the Soulseek
client spawns a download's transfer task with tokio::spawn, which needs a
runtime on the calling thread — the engine's resolver thread is not a
Tauri command, the spawn panicked, the thread died without a word, and
the card sat on BUFFERING; every client call from an engine thread runs
under the cold runtime's enter() now.

The phone's artist page never ran the MusicBrainz pass. It built its
discography from Deezer's list alone, so the releases Deezer's artist
listing omits — EPs, and whole albums — never appeared, nor the
authoritative release types or the guest-appearance and co-tenant
arbitration. The desktop's identity merge is a plain module now
(lib/artistIdentity.js, moved out of state.svelte.js verbatim, the
desktop importing it back), and the phone runs it after the page is on
screen — one implementation for both surfaces.

What the restarted head unit found, all in the seam between a car session
and the page — and one bug older than this branch.

The pump's calls into Kotlin never worked. "auto advanced to" has not
appeared in a single log while cold queues advanced through whole albums
under a card stuck on track one, and the new end-of-queue notice did not
fire either. FindClass on the self-attached pump thread resolves through
the system class loader, which cannot see com.soulstream.mediasession
.AutoNative; the lookup failed and the exception was cleared, silently,
every time. The class is taken from the object Kotlin hands every JNI
entry — on a Java thread, through the app's own loader — and kept as a
global reference; every pump call goes through call_auto_native.

Autoplay on connection worked unprompted and resumed Starboy at 3:50 of
3:50: the page writes the position one last time when a track runs out, so
a resume point can sit at the end. resume_queue treats a position within
two seconds of the duration as "the next item from the top", keeps every
entry (one not here is found on reach, like any other), and the headless
engine reports end-of-queue to Kotlin, which turns the card to paused. A
seek on the page reached the resume point only at the pump's next
five-second flush; seekSecs writes the session at once.

Opening the app over a cold car session handed the page the right queue
and the page called it a paused session that needed re-sourcing: the
transport snapshot it reads is built only from events after the page
subscribes, and an engine already playing sends no Playing. The bar said
paused, the card said PAUSED, the audio kept going. playback::init seeds
the snapshot from the engine's own progress when it adopts a running
engine, so the page follows what is sounding.

preview::init opened two more output streams at every launch for the
desktop's hover previews, which the phone never calls — three live AAudio
tracks on our uid with one sounding. It is gated off on Android.
This commit is contained in:
KeelrLeek committed 2026-09-18 15:00:11 -05:00
1 parent 5ddb6d4d59
commit 9f7661afab
22 files changed
+3069 -485

No files matched your search

@@ -862,6 +862,31 @@ object LibraryTree : MediaLibrarySession.Callback {
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
* a browse, it asks for a node and draws what comes back.
*/
/**
* A browser asking to be told when a parent's rows change. media3's
* DEFAULT answers "not supported", which means nobody is ever
* subscribed and `notifyChildrenChanged` reaches nobody — the cover
* hunt (cover_hunt.rs) found covers for a whole playlist and the car
* kept showing the blank list until it was left and re-entered
* (2026-09-18). Accepting is all it takes: the host re-lists the parent
* on every notice from then on.
*/
override fun onSubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> {
android.util.Log.i("SoulStream", "auto subscribe $parentId by " + browser.packageName)
return Futures.immediateFuture(LibraryResult.ofVoid())
}
override fun onUnsubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
): ListenableFuture<LibraryResult<Void>> = Futures.immediateFuture(LibraryResult.ofVoid())
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@@ -1566,6 +1591,90 @@ object AutoNative {
}
}
/**
* The fallback queue ran out — called FROM Rust, on the audio pump, like
* [headlessTrack]. The card goes to "paused, at the end": media3 was
* extrapolating a PLAYING position over silence for as long as anyone
* looked, because nothing had told it the engine stopped.
*/
/**
* A stream's arrival, called FROM Rust on the audio pump: "buffering"
* while the first bytes are on their way (the card shows a spinner),
* "playing" once they sound, "failed" with the reason when no copy ever
* delivered — which becomes the error card, not silence.
*/
@JvmStatic
fun headlessState(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val o = org.json.JSONObject(json)
when (o.optString("state")) {
"buffering" -> MediaHub.state?.let { it.buffering = true; MediaHub.update(ctx, it) }
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; MediaHub.update(ctx, it) }
"failed" -> {
MediaHub.state?.buffering = false
val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
MediaHub.fail(ctx, why ?: "SoulStream could not play that.")
}
}
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto state card failed: " + e)
}
}
@JvmStatic
fun headlessEnded(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val np = MediaHub.state ?: return
np.playing = false
np.duration?.let { np.position = it }
np.hasNext = false
MediaHub.update(ctx, np)
android.util.Log.i("SoulStream", "auto queue ended")
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto ended card failed: " + e)
}
}
/**
* The cover under the track that is showing changed — a picture pulled
* out of the file once enough of it landed, or the catalog's answer
* (cover_hunt.rs). Only the art moves; the position stays.
*/
@JvmStatic
fun headlessArt(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val np = MediaHub.state ?: return
val o = org.json.JSONObject(json)
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } ?: return
np.artKey = art
np.art = artB64(art)
MediaHub.update(ctx, np)
android.util.Log.i("SoulStream", "auto cover landed for " + np.title)
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto art card failed: " + e)
}
}
/**
* Rows of a browse parent have covers they did not have when the car
* last listed it (cover_hunt.rs): tell the host to list it again.
* Int.MAX_VALUE is media3's own "count unknown".
*/
@JvmStatic
fun headlessCovers(json: String) {
try {
val parent = org.json.JSONObject(json).optString("parent").ifEmpty { return }
val session = MediaHub.sessionOrNull() ?: return
session.notifyChildrenChanged(parent, Int.MAX_VALUE, null)
android.util.Log.i("SoulStream", "auto covers landed, re-listing " + parent)
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto re-list failed: " + e)
}
}
@JvmStatic
fun headlessTrack(json: String) {
try {
@@ -28,6 +28,10 @@ class NowPlayingArgs {
var art: String? = null
var hasNext: Boolean = true
var hasPrev: Boolean = true
/** the bytes are still on their way (a cold fetch from the network);
* the car shows a spinner rather than a position. Never sent by the
* page — its own player folds buffering into `playing`. */
var buffering: Boolean = false
/** the queue around the current item — it and up to ten either side.
* Since round forty-eight SoulStreamPlayer.getState() turns this into
* the media3 playlist the card is built from, and the index into it.
@@ -123,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
per rebuild and is always true. */
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
var sounding = np.playing
var buffering = false
if (headless()) {
posMs = AutoNative.positionMs()
sounding = AutoNative.sounding()
/* a cold fetch whose first bytes are still on their way: the car
draws a spinner, and media3 does not extrapolate a position
that is not moving */
buffering = np.buffering && !sounding
}
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(np))
/* STATE_READY even when paused. shouldShowNotification treats
STATE_IDLE as "nothing to show" unless it has been prepared and
stopped, so a paused player reporting IDLE loses its card.
There is no buffering state to report either: media.svelte.js
already folds `buffering` into `playing`. */
.setPlaybackState(Player.STATE_READY)
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
The page's own player folds buffering into `playing`; only the
headless fetch reports BUFFERING, above. */
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlaylist(items)
.setCurrentMediaItemIndex(at)
/* The long overload, not a PositionSupplier: State's own
@@ -381,10 +386,15 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
// Nothing on this phone can play it, and the process is
// cold: the focus goes back rather than being held for
// silence — and the car is TOLD, which it never was
// before. A streamed row with no cache copy needs the
// resolver, which needs the app.
// before. A streamed row is fetched from the network by
// this process now (headless_slsk.rs); `error` is its
// reason when that could not even start.
MediaHub.dropFocus(ctx.applicationContext)
if (o.optBoolean("remote", false)) {
val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null }
if (why != null) {
android.util.Log.i("SoulStream", "auto cold fetch refused: $why")
MediaHub.fail(ctx.applicationContext, why)
} else if (o.optBoolean("remote", false)) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
MediaHub.fail(
ctx.applicationContext,
@@ -412,6 +422,9 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
own position takes over from the first tick */
np.position = o.optLong("positionMs", 0L) / 1000.0
np.playing = true
/* a cold fetch: the title now, a spinner until the first
bytes play (headlessState turns it off) */
np.buffering = o.optBoolean("resolving", false)
np.hasNext = o.optBoolean("hasNext", queued > 1)
np.hasPrev = false
// The cover, which is also the colour: Android Auto washes