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:
1 parent
8f46f9c663
commit
17942fc04b
28 files changed
+2600
-396
No files matched your search
+281
-185
@@ -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.
|
||||
Reference in new issue
Block a user