android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over

Four things the car could not do, all from a process with no Activity.

A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.

The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.

The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.

Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.

Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.

The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.

The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
This commit is contained in:
KeelrLeek committed 2026-09-18 12:35:54 -05:00
1 parent 8f46f9c663
commit 17942fc04b
28 files changed
+2600 -396

No files matched your search

+281 -185
View File
@@ -762,80 +762,80 @@ screen — off Windows they report None and 0.
## Background playback and the lock-screen card
Nothing in this plugin starts the service. It is **bound**, and that is half
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
action, a custom action, or media3's own self-start intent — which it
recognises by a uid extra of its own making — and returns `START_STICKY`
without calling `startForeground()` for anything else; a plain
`Intent(ctx, MediaService::class.java)` is anything else. So the old
`startForegroundService` would now arm Android's ten-second timer and satisfy
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
whole app about ten seconds after a foreground start that never called
`startForeground()`, and on that day the first real Save died with the
process, four tracks in. media3 promotes ITSELF, once there is something to
promote for — `MediaNotificationManager.startForeground()` does the
`startForegroundService` and the `setForegroundServiceNotification` in one
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
with the action `androidx.media3.session.MediaSessionService`, which creates
the service, is not subject to the Android 8+/12+ background-start
restriction, and arms no timer at all. The check is unchanged and one step
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
The other half is `MediaService.onStartCommand`, because the **system** starts
this service in the foreground whether media3 then wants it there or not.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService` — unconditionally, for every key.
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
falls to the `androidx.media3.session.MediaSessionService` filter in the
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
`setStartAsForegroundService(!player.playWhenReady)`, but
`PlaybackPendingIntentBuilder.build()` only honours it for
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
anyway; every other card button is a plain `getService`. The KEYS are the
problem. A key press arms the ten-second timer, and media3 promotes only for a
session it calls *user-engaged*: `playWhenReady` and
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
cannot resolve — a null session from `onGetSession` (ours never is), a custom
action it cannot name (we declare none), a stale self-start intent from a
destroyed instance. Three presses qualify for none of it and would each take
the app down about ten seconds later: a key into a process the system built
*for* that key; a key on a card paused longer than ten minutes, once the tail
has run out and the press does not start playback; and any key at all with the
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
alive for. So
`onStartCommand` promotes first, before `super` — with media3's own card
through media3's own `onUpdateNotification(session, true)` when there is
something on the bar, so its `startedInForeground` bookkeeping stays true and
the shade never sees a stand-in, and otherwise with a plain notification on an
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
`startForeground` and `stopForeground` are **final**, so none of this can be
done by watching them; the split above is what keeps it from fighting the
library, because media3 cannot be in the foreground with nothing on the bar —
an empty timeline fails `shouldShowNotification`, which removes the
notification and demotes.
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
hands its `android/` Gradle module to tauri-build (which writes it into
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
and exposes `update` / `clear`, and the Kotlin side is three files — the
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
needs a media3 session to browse, and the rounds after this one build on the
Nothing in this plugin starts the service. It is **bound**, and that is half
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
action, a custom action, or media3's own self-start intent — which it
recognises by a uid extra of its own making — and returns `START_STICKY`
without calling `startForeground()` for anything else; a plain
`Intent(ctx, MediaService::class.java)` is anything else. So the old
`startForegroundService` would now arm Android's ten-second timer and satisfy
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
whole app about ten seconds after a foreground start that never called
`startForeground()`, and on that day the first real Save died with the
process, four tracks in. media3 promotes ITSELF, once there is something to
promote for — `MediaNotificationManager.startForeground()` does the
`startForegroundService` and the `setForegroundServiceNotification` in one
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
with the action `androidx.media3.session.MediaSessionService`, which creates
the service, is not subject to the Android 8+/12+ background-start
restriction, and arms no timer at all. The check is unchanged and one step
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
The other half is `MediaService.onStartCommand`, because the **system** starts
this service in the foreground whether media3 then wants it there or not.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService` — unconditionally, for every key.
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
falls to the `androidx.media3.session.MediaSessionService` filter in the
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
`setStartAsForegroundService(!player.playWhenReady)`, but
`PlaybackPendingIntentBuilder.build()` only honours it for
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
anyway; every other card button is a plain `getService`. The KEYS are the
problem. A key press arms the ten-second timer, and media3 promotes only for a
session it calls *user-engaged*: `playWhenReady` and
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
cannot resolve — a null session from `onGetSession` (ours never is), a custom
action it cannot name (we declare none), a stale self-start intent from a
destroyed instance. Three presses qualify for none of it and would each take
the app down about ten seconds later: a key into a process the system built
*for* that key; a key on a card paused longer than ten minutes, once the tail
has run out and the press does not start playback; and any key at all with the
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
alive for. So
`onStartCommand` promotes first, before `super` — with media3's own card
through media3's own `onUpdateNotification(session, true)` when there is
something on the bar, so its `startedInForeground` bookkeeping stays true and
the shade never sees a stand-in, and otherwise with a plain notification on an
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
`startForeground` and `stopForeground` are **final**, so none of this can be
done by watching them; the split above is what keeps it from fighting the
library, because media3 cannot be in the foreground with nothing on the bar —
an empty timeline fails `shouldShowNotification`, which removes the
notification and demotes.
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
hands its `android/` Gradle module to tauri-build (which writes it into
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
and exposes `update` / `clear`, and the Kotlin side is three files — the
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
needs a media3 session to browse, and the rounds after this one build on the
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
list and open the app at all, and `SoulStreamPlayer` is a
`SimpleBasePlayer` — media3's
supported way to put a non-ExoPlayer engine behind a session, which is exactly
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
last `NowPlayingArgs` off the bridge, queue window included) and every
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
`SimpleBasePlayer` — media3's
supported way to put a non-ExoPlayer engine behind a session, which is exactly
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
last `NowPlayingArgs` off the bridge, queue window included) and every
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
`src/mobile/media.svelte.js`, which did not change.
The session, the player, the notification provider, the audio-focus request
@@ -873,75 +873,75 @@ dies; the walk's `one-session` check is what keeps that honest.
`MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs
`auto service destroyed`. Those two lines are the only way to count the loop
on a real phone — see "### The Auto prober".
Four things in that file are load-bearing and silent when they break. The
playlist **must never be empty while a track exists**:
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
and the card then never appears, with nothing in any log — so `getState()`
falls back to a one-item playlist rather than trusting the window. Today's
frontend cannot actually send an empty window with a track on it
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
there because a frontend out of step with this plugin, or a `queue` that did
not survive the two JSON hops, would take the card down silently.
`COMMAND_GET_TIMELINE` and
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
down to what its commands allow — one window without the first,
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
same refusal from the other end. A paused player must report `STATE_READY`,
not `STATE_IDLE`, or the card
goes when the music does. And both spellings of next and previous must be
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
alone and the button appears and does nothing.
Two things media3 does NOT do for a custom player, whatever its own API
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
wording is "true if **the player** should handle audio focus".
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
"another app takes the output → we pause" and "headphones out → we pause",
and no walk on this phone can catch either.
The transport handlers do not answer media3 immediately. A play, a pause or a
seek is a round trip out to the WebView and back, and an instant answer has
media3 call `getState()` on the spot — which still reports the OLD state, so
the lock screen flips back before it flips forward. Each returns an
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
while one is outstanding media3 shows its own optimistic placeholder, which
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
regardless, because `invalidateState()` deliberately does nothing while
anything is pending: a future that is never completed does not degrade the
card, it freezes it.
Two behaviours changed with the migration, both defensible, both of them
looking like bugs if nobody wrote them down. After a pause the service stays
in the foreground for up to ten minutes (media3's
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
at once. And `clear` leaves the service alive with an idle session registered
rather than stopping it, so a headset button still reaches us with nothing on
the bar; the audio focus the old service gave back by dying is now abandoned
by hand in `MediaHub.clear`.
What did NOT change, though it is the first thing a reading of the API
suggests: previous and next still work everywhere the old card did, including
at the very top of a queue. The card's buttons are delivered as key events —
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
`BasePlayer` falls back to seeking the current item when there is no previous
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
else. So the app's own `prev()` — five-second restart rule and all — runs on
every press. The one button that really can go missing is next on the last row
with repeat off, because `hasNext` is false there and the command is not
on a real phone — see "### The Auto prober".
Four things in that file are load-bearing and silent when they break. The
playlist **must never be empty while a track exists**:
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
and the card then never appears, with nothing in any log — so `getState()`
falls back to a one-item playlist rather than trusting the window. Today's
frontend cannot actually send an empty window with a track on it
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
there because a frontend out of step with this plugin, or a `queue` that did
not survive the two JSON hops, would take the card down silently.
`COMMAND_GET_TIMELINE` and
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
down to what its commands allow — one window without the first,
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
same refusal from the other end. A paused player must report `STATE_READY`,
not `STATE_IDLE`, or the card
goes when the music does. And both spellings of next and previous must be
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
alone and the button appears and does nothing.
Two things media3 does NOT do for a custom player, whatever its own API
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
wording is "true if **the player** should handle audio focus".
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
"another app takes the output → we pause" and "headphones out → we pause",
and no walk on this phone can catch either.
The transport handlers do not answer media3 immediately. A play, a pause or a
seek is a round trip out to the WebView and back, and an instant answer has
media3 call `getState()` on the spot — which still reports the OLD state, so
the lock screen flips back before it flips forward. Each returns an
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
while one is outstanding media3 shows its own optimistic placeholder, which
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
regardless, because `invalidateState()` deliberately does nothing while
anything is pending: a future that is never completed does not degrade the
card, it freezes it.
Two behaviours changed with the migration, both defensible, both of them
looking like bugs if nobody wrote them down. After a pause the service stays
in the foreground for up to ten minutes (media3's
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
at once. And `clear` leaves the service alive with an idle session registered
rather than stopping it, so a headset button still reaches us with nothing on
the bar; the audio focus the old service gave back by dying is now abandoned
by hand in `MediaHub.clear`.
What did NOT change, though it is the first thing a reading of the API
suggests: previous and next still work everywhere the old card did, including
at the very top of a queue. The card's buttons are delivered as key events —
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
`BasePlayer` falls back to seeking the current item when there is no previous
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
else. So the app's own `prev()` — five-second restart rule and all — runs on
every press. The one button that really can go missing is next on the last row
with repeat off, because `hasNext` is false there and the command is not
advertised at all, which is correct.
The glyph in the status bar is the falcon, not a note.
@@ -1073,16 +1073,16 @@ FAILS rather than skips when it cannot be armed — a queue under three rows, or
repeat-one, under which `next()` seeks instead of stepping — with the reason
in the message.
### What the walk proves about the card, and what it does not
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
one card, on the `playback` channel, carrying the playing track's own title
(`media-card`, `one-card`); the system holding one media session for the app,
PLAYING (`media-session`, `one-session`); media keys reaching the player
through that session with the app in front (`media-keys`); ten seconds in the
background with the position still advancing (`background`); the paused
restore waited out past 25 s with the pid intact and no
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
### What the walk proves about the card, and what it does not
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
one card, on the `playback` channel, carrying the playing track's own title
(`media-card`, `one-card`); the system holding one media session for the app,
PLAYING (`media-session`, `one-session`); media keys reaching the player
through that session with the app in front (`media-keys`); ten seconds in the
background with the position still advancing (`background`); the paused
restore waited out past 25 s with the pid intact and no
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
queue window arriving across the bridge, field for field (`queue-window`);
and both of the intent filters Android Auto knocks on — the legacy
`android.media.browse.MediaBrowserService` and media3's own
@@ -1103,19 +1103,106 @@ last resolved it (`auto-desc`).
**The browse tree is not proven here and cannot be.** With the app
force-stopped there is no WebView, so no DevTools socket, so `smoke.mjs` has
nothing to attach to; `tools/auto-prober/` is the instrument for that, and
"### The Auto prober" under Verifying is where it lives.
**Kyle's hands only. Nothing here can be automated, and the walk going green
says nothing about any of it.** Unplugging headphones pauses the music —
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
audio focus pauses this one, and giving it back resumes it. The card's LOOK:
the cover, and the falcon in the status bar — `dumpsys notification` prints a
resource id and never a pixel, so a correct glyph and a white rectangle read
identically. And a Bluetooth headset's own buttons, which take a path
`input keyevent` does not. These four are the first things to try after the
"### The Auto prober" under Verifying is where it lives.
**Kyle's hands only. Nothing here can be automated, and the walk going green
says nothing about any of it.** Unplugging headphones pauses the music —
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
audio focus pauses this one, and giving it back resumes it. The card's LOOK:
the cover, and the falcon in the status bar — `dumpsys notification` prints a
resource id and never a pixel, so a correct glyph and a white rectangle read
identically. And a Bluetooth headset's own buttons, which take a path
`input keyevent` does not. These four are the first things to try after the
first deploy of anything that touches this plugin.
## Android Auto: cold playback, resume, and what the car is told
The car binds `MediaService` with the app COLD — no Activity, no WebView, no
`AppHandle` — and everything below is about making that process play what
it can, say what it cannot, and pick up where the phone left off. Four
pieces, all of them landed 2026-09-18:
**What plays cold.** Library tracks always did. Now a playlist's streamed
rows play cold too, when their bytes are already on the phone: the page's
memory of which peer file a catalog track resolved to
(`soulstream.streamSources.v1` in localStorage) is mirrored to
`<data>/stream-sources.json` (`stream_sources.rs`) — one entry every time a
source is remembered, and the whole store once at boot for memories made
before the mirror existed — and `auto_browse.rs` looks a streamed row up
there, computes its cache file with the same hash `playback::cache_path`
uses, and calls it playable when the file is byte-complete. Such a row is
"artist · album" in the car; one that is not is "artist · needs the app",
which is the amber-dot row of the design. A playlist's subtitle counts
cached streams as "here". A tap on a row that needs the app is handed to
the app when it is up (`playRemote`), exactly as before.
**Keep offline.** A playlist's More sheet has "Keep offline"
(`src/mobile/offline.svelte.js`, the list in `config.json`
`offline_playlists`): every streamed row is fetched into the stream cache in
the background — the remembered peer file first, else the resolver's ladder,
then the same `preload_stream` the player uses for the next track, one row
at a time, polled until whole — and pinned (`keep` in the mirror), which the
cache pruner honours, so the retention window cannot undo it. The fetch
rides the downloads notice in the shade, and that is also what keeps the
process alive once the app leaves the screen; Android 12+ lets that service
start only from the foreground, so a pass that begins on screen keeps going
in a pocket. The playlist header says "Kept offline · N of M here" once a
pass has looked.
**Resume, like every other player.** Where playback was is written to
`<data>/resume.json` (`resume.rs`) by two hands: the page names the queue
(a window of 25 either side of the current track, each entry by the file
that plays it cold — a local path, or the peer file whose cache may hold it)
every time it writes its own session, and the engine's pump keeps the
position, every five seconds while playing and at once on a pause or seek.
Three things read it. media3's `MediaButtonReceiver` is now declared, so a
PLAY key into a dead app — a Bluetooth head unit's button, the Quick
Settings resume card, Android Auto's autoplay on connection — starts the
service and lands in `LibraryTree.onPlaybackResumption`, which answers with
the `ss:resume` row and the position; media3 sets it, and
`play_media_id("ss:resume", startMs)` starts the saved queue at the saved
entry and seeks. A host asking for the RECENT root (`EXTRA_RECENT`) gets a
root whose one child is that row, "Continue · <record> · m:ss". And the
Recent tab lists it first. An entry whose file is gone is skipped; if the
one that was playing is gone, the next playable one starts at zero rather
than three minutes into a different song.
**The handover.** When the app opens over a car session the engine is
already playing a queue the page has never heard of. `playback::init` takes
that queue off the headless engine before clearing it, `player_state` hands
it over once, and `restoreSession` builds the bar from it — instead of
restoring the page's own older session over live audio, which put the wrong
title on the bar.
**What the car is told.** A tap that cannot play — a streamed row with no
cache copy while the app is cold, a hand-off the resolver refused, a row
whose file is gone — used to be silence. `MediaHub.fail` now puts the reason
in the playback state (`STATE_ERROR` with the message, which Auto draws as a
card) and sends a `SessionError` carrying "Open SoulStream" as the
resolution action; the next track that plays withdraws it. The page reports
its own verdict back through `remote_failed` when a hand-off fails.
**The notification storm, found and fixed.** 2026-09-18's logcat had the
card re-posted six times a second for over a minute, then "Too many
PendingIntent created" (media3 mints a fresh one for the custom button on
every rebuild) and twenty refused foreground starts in two hundred
milliseconds. The cause was a loop between `MediaService.onStartCommand` and
media3: media3 starts the service ITSELF on every foreground notification
update (`MediaNotificationManager.startForeground` →
`startForegroundService(startSelfIntent)`, a bare component intent with a
uid extra and nothing else), and `onStartCommand` promoted on every intent
— re-posting the card, which made media3 start the service again. It ran
for as long as the service was in the foreground: while a track played, and
for the ten-minute tail after it paused (the capture shows it stopping at
exactly pause + 10 min). `onStartCommand` now promotes only for an intent
that carries an action or a data uri — a media key, a card button — and
leaves media3's self-start to media3. Separately, every `refresh()` of the
media3 player goes through `MediaHub.refreshSoon`, which coalesces asks
inside a 250 ms window and logs `card asked to rebuild Nx in 10s` when the
rate is silly; it stayed silent through the storm, which is what pointed
away from our own rebuilds and at the service restart.
## What a screen reader says
Every control on the phone is named by what it does, in the app's own
@@ -1186,6 +1273,26 @@ would notice.
## When things go wrong
**The network comes back on its own (2026-09-18).** A Soulseek server
connection dies for reasons that are nobody's: the phone changes networks,
Android freezes a backgrounded process for long enough that the five-minute
keepalive never goes out and the server closes the socket, a laptop sleeps.
Until now every one of those ended as `Event::Disconnected`, the page stamped
"offline", and every streamed track after that was refused with "sign in on
Community" until somebody opened the login card — in a car, nobody does.
`commands::schedule_reconnect` now dials the dropped session back with the
stored credentials: 5 s, 15 s, 45 s, 2 min, then every 5 min, with jitter,
which stays well under the server's rapid-login throttle (eight to ten and it
silently drops the handshake). The page hears `soulseek:state { kind:
"reconnecting", attempt, in_secs }` and shows "connecting" rather than
"offline"; the resolver waits up to twenty seconds for a dial in progress
before refusing a track. An explicit Disconnect ends the wish; an explicit
login starts a new generation, and a drop reported by an older session's
pump — which used to stamp a fresh session offline, a race that was there
all along — now says nothing. Debug builds expose `debug_drop_connection`
(`__ss.api.debugDropConnection()`), which severs the socket exactly the way a
network drop would, so the redial can be watched.
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
a dead cell, or holding a library folder that has gone away, and until this
round every one of those showed up as an empty box, a spinner with no end, or
@@ -2413,27 +2520,16 @@ symptom.
the share sheet, which no walk reaches. Work down the smoke list in "The
release build" before trusting a release APK on a phone.
- **Android Auto can see the app, and there is nothing inside.** Round
forty-nine made `MediaService` a `MediaLibraryService`, put three actions
on one intent filter (media3's `MediaLibraryService` and
`MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds), and
added `res/xml/automotive_app_desc.xml` with the
`com.google.android.gms.car.application` meta-data that declares the app to
Auto. So Auto lists SoulStream, opens it, and gets a root — immediately,
from `Futures.immediateFuture`, with nothing on the main thread — and the
root is empty. `onGetChildren` is not overridden, so the default answers
`ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in
it. What is still missing is everything under it: the library read out of
the database **read-only** from a process with no Activity and no Tauri
(`crates/library` is plain rusqlite and `JNI_OnLoad` publishes the Context
at `System.loadLibrary` time, so browse can work cold), playing what it
lists — which needs `COMMAND_SET_MEDIA_ITEM` and its neighbours, still
deliberately unadvertised, plus the `queueBase` → `playQueueAt()` mapping —
and search. Catalog and Soulseek rows will never play from cold: the
resolver is 872 lines of shared JS and Soulseek needs a live session, so
they get `STATE_ERROR` with a resolution intent instead, which is also what
Google's quality bar asks for. The walk proves the two front doors resolve
(`auto-browse`, `auto-library`) and `tools/auto-prober/` proves the root
comes back cold; the car screen itself still needs the Desktop Head Unit or
a car.
- **Android Auto, what is still not there.** The tree, cold playback of
library tracks and cached streams, search and voice, resume and autoplay,
and the error card are in (see "Android Auto: cold playback, resume, and
what the car is told"). Not yet: a streamed row with NO cache copy still
cannot play from a cold process — the resolver is shared JS and Soulseek
needs a live session — and the car says so and offers to open the app;
"Keep offline" is the answer for a playlist that matters. **Save**, the
design's second transport action, is unbuilt. The car's own plays are not
written to `plays` (the headless process opens the library read-only by
rule), so a record heard only in the car does not climb the phone's Recent
— the resume row covers the common case. Catalog browsing beyond
playlists, the queue as a browsable list, and the `queueBase` →
`playQueueAt()` mapping for tapping a queue row are still absent.