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.
@@ -49,6 +49,26 @@
</intent-filter>
</service>
<!-- media3's own media-button receiver, and the reason the app can
start COLD from a PLAY key: a Bluetooth head unit's play button,
the Quick Settings "resume" card after a reboot, and Android
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
at a process that does not exist. The receiver starts the
service in the foreground (MediaService.onStartCommand promotes
at once, see holdForeground) and media3 then asks
LibraryTree.onPlaybackResumption what to play. It forwards
ONLY play / play-pause / headset-hook into a dead app — a next or
a pause into nothing is dropped, not started. While the app is
alive the framework talks to the session directly and the
receiver is not involved. -->
<receiver
android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
@@ -115,16 +115,24 @@ import org.json.JSONArray
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
/**
* Play something this process cannot resolve on its own — a catalog or
* Soulseek row — by handing it to the app, which has the resolver.
* Play a browse row THROUGH THE APP: the page owns the queue while it
* exists (shuffle, repeat, failover, the resolver, the session it
* restores), so every car tap is handed to it whole — a library album, a
* playlist row, the resume row — rather than started on the engine
* underneath it, which left the bar naming one track while another
* played. The first argument is the row as `AutoNative.describe` spells
* it for the page; the second the position to start at, or -1.
*
* Non-null ONLY while the plugin is loaded, which means only while a
* WebView exists. That is exactly the test for "is the app warm", so it
* doubles as the discriminator: set, and the car's tap becomes the app's
* tap; null, and there is nothing on this phone that could play it.
* tap; null, and the engine in this process plays what it can itself.
*/
@Volatile var onRemote: ((Long, Long) -> Unit)? = null
@Volatile var onPlay: ((String, Long) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
/** Why the last thing asked for did not play, for the car screen; null
* the moment anything plays. See [fail]. */
@Volatile var error: String? = null
/** the cover as ENCODED bytes — media3 decodes them itself */
@Volatile var artBytes: ByteArray? = null
@Volatile var artKey: String? = null
@@ -199,6 +207,24 @@ object MediaHub {
}
}
state = np
/* something is on the bar again: whatever went wrong last is over —
and the error a legacy controller was SENT is withdrawn explicitly,
because a head unit keeps showing one until told otherwise */
val hadError = error != null
error = null
if (hadError) {
mainHandler.post {
try {
libSession?.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.INFO_CANCELLED, "",
),
)
} catch (_: Exception) {
/* a controller that has gone; nothing to withdraw from */
}
}
}
bind(app)
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
verifyApplicationThread(), a Tauri @Command is not promised to run
@@ -211,9 +237,108 @@ object MediaHub {
because the update that MADE the service found `service` still
null; there is nothing to catch up now. */
if (np.playing) requestFocus(app)
player?.let {
it.settle()
it.refresh()
player?.settle()
refreshSoon()
}
}
/* ── the card's rebuild rate ─────────────────────────────────────────
Every refresh() is a getState(), a diff, a PlayerInfo to every
connected controller (seventeen of them on 2026-09-18, after a day
of head-unit sessions), and — when anything the card shows moved — a
notification re-post, which under media3 mints a fresh PendingIntent
for the custom button each time. This 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", then twenty refused foreground starts in two hundred
milliseconds. Whatever asks that often, the answer is the same: the
card is a mirror, and a mirror that is repainted more than a few
times a second is not showing anything new. So refreshes are
COALESCED — the first lands at once, anything inside the window
folds into one rebuild at the window's end — and counted, so the
next storm has a number beside it in the log instead of a mystery. */
private const val REFRESH_WINDOW_MS = 250L
@Volatile private var refreshPending = false
@Volatile private var lastRefreshAt = 0L
private var refreshes = 0
private var refreshAsks = 0
private var rateSince = 0L
private val refreshNow = Runnable {
refreshPending = false
lastRefreshAt = android.os.SystemClock.uptimeMillis()
refreshes += 1
player?.refresh()
}
/** Ask for a state rebuild. Main thread only. */
fun refreshSoon() {
val now = android.os.SystemClock.uptimeMillis()
refreshAsks += 1
if (rateSince == 0L) rateSince = now
if (now - rateSince >= 10_000L) {
if (refreshAsks > 20) {
android.util.Log.w(
"SoulStream",
"card asked to rebuild " + refreshAsks + "x in 10s (" + refreshes + " done)",
)
}
refreshAsks = 0
refreshes = 0
rateSince = now
}
if (refreshPending) return
val since = now - lastRefreshAt
if (since >= REFRESH_WINDOW_MS) {
refreshNow.run()
} else {
refreshPending = true
mainHandler.postDelayed(refreshNow, REFRESH_WINDOW_MS - since)
}
}
/**
* Something the car (or a headset) asked for could not play: the row
* needs the app and the app is not up, the resolver found no source,
* the network is signed out. Until now every one of those was silence
* on the head unit. This puts the reason where a driver looks — the
* playback state goes to error with the message, which Android Auto
* draws as a card — and offers "Open SoulStream" as the way out, the
* resolution the design asked for.
*
* Cleared by the next [update]: a track playing is the error being over.
*/
fun fail(ctx: Context, message: String) {
val app = ctx.applicationContext
error = message
android.util.Log.i("SoulStream", "auto error shown: " + message)
mainHandler.post {
refreshSoon()
val s = libSession ?: return@post
try {
val extras = android.os.Bundle()
extras.putString(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT,
"Open SoulStream",
)
app.packageManager.getLaunchIntentForPackage(app.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
extras.putParcelable(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT,
PendingIntent.getActivity(
app, 7, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
s.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.ERROR_INVALID_STATE,
message,
extras,
),
)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "auto error could not be sent: $e")
}
}
}
@@ -232,10 +357,8 @@ object MediaHub {
artKey = null
mainHandler.post {
dropFocus(app)
player?.let {
it.settle()
it.refresh()
}
player?.settle()
refreshSoon()
}
}
@@ -489,12 +612,12 @@ object MediaHub {
onAction?.invoke(action, position)
}
/** True when the app is up and its resolver can be reached. */
fun warm(): Boolean = onRemote != null
/** True when the app is up: the page owns the queue and plays every tap. */
fun warm(): Boolean = onPlay != null
/** Hand a playlist row to the app to play. */
fun remote(playlist: Long, item: Long) {
onRemote?.invoke(playlist, item)
/** Hand a row to the app to play, as `AutoNative.describe` spelled it. */
fun play(desc: String, startMs: Long) {
onPlay?.invoke(desc, startMs)
}
}
@@ -593,9 +716,90 @@ object LibraryTree : MediaLibrarySession.Callback {
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST)
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
val rootParams = LibraryParams.Builder().setExtras(b).build()
// The RECENT root is a different question — "what would you continue?"
// — asked by Android's own resumption surfaces and by Auto. It is
// answered with a root whose one child is the resume row
// (auto_browse.rs RECENT_ROOT), read out of resume.json and never the
// database; a host that gets the library tree here would draw four
// tabs where it wanted one song.
if (params?.isRecent == true) {
return Futures.immediateFuture(LibraryResult.ofItem(recentRootItem(), rootParams))
}
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams))
}
/** The id auto_browse.rs answers the resume row for. */
const val RECENT_ROOT = "ss:recent-root"
private fun recentRootItem(): MediaItem =
MediaItem.Builder()
.setMediaId(RECENT_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Continue")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
/**
* "Pick up where you left off", asked by a PLAY that reaches a player
* with nothing in it: a Bluetooth head unit's button, the Quick Settings
* resume card, Android Auto's autoplay on connection. media3 takes what
* comes back, sets it on the player, prepares and plays — so this
* answers with the resume row (`ss:resume`) and the position, and
* [SoulStreamPlayer.handleSetMediaItems] turns that into the saved queue
* at the saved spot. `isForPlayback` false is the same question asked to
* DRAW a card; the answer is identical and nothing starts.
*
* Off the calling thread: it reads resume.json and stats a few files.
*/
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
isForPlayback: Boolean,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.submit<MediaSession.MediaItemsWithStartPosition>(
{
val ctx = MediaHub.appContext ?: throw IllegalStateException("no context")
val o = org.json.JSONObject(AutoNative.resumeInfo(ctx.dataDir.absolutePath))
if (!o.optBoolean("ok", false)) {
android.util.Log.i("SoulStream", "auto resume asked, nothing to resume")
throw UnsupportedOperationException("nothing to resume")
}
val md = MediaMetadata.Builder()
.setTitle(o.optString("title"))
.setArtist(o.optString("artist"))
.setAlbumTitle(o.optString("album"))
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
artUri(ctx, art, controller.packageName)?.let { md.setArtworkUri(it) }
val item = MediaItem.Builder()
.setMediaId(o.optString("id", "ss:resume"))
.setMediaMetadata(md.build())
.build()
val pos = o.optLong("positionMs", 0L)
android.util.Log.i(
"SoulStream",
"auto resume " + (if (isForPlayback) "for playback" else "to show") +
": " + o.optString("title") + " at " + pos + "ms",
)
MediaSession.MediaItemsWithStartPosition(ImmutableList.of(item), 0, pos)
},
io,
)
/** The older spelling of the same question, for a host on the old path. */
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
onPlaybackResumption(mediaSession, controller, true)
/**
* The icon a root tab is drawn with — the thing that turns four words into
* four tabs with pictures over them, which is what every other media app
@@ -956,7 +1160,21 @@ object LibraryTree : MediaLibrarySession.Callback {
val tab = tabIcon(ctx, id)
if (tab != null) md.setArtworkUri(tab)
else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
styleExtras(id)?.let { md.setExtras(it) }
val extras = styleExtras(id) ?: android.os.Bundle()
// A playlist row whose file is on this phone — a local track,
// or a streamed one whose cache copy is whole — wears the
// host's own "downloaded" mark. It is the design's reach
// signal turned the safe way round: the rows that play with
// the app shut are the marked ones, and the mark thins the
// "needs the app" rows out as more of the list is kept.
val here = id.startsWith("ss:pitem:") && !o.isNull("path") && o.optString("path").isNotEmpty()
if (here) {
extras.putLong(
androidx.media3.session.MediaConstants.EXTRAS_KEY_DOWNLOAD_STATUS,
androidx.media3.session.MediaConstants.EXTRAS_VALUE_STATUS_DOWNLOADED,
)
}
if (!extras.isEmpty) md.setExtras(extras)
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
}
} catch (e: Throwable) {
@@ -1135,8 +1353,27 @@ class MediaService : MediaLibraryService() {
stops. Promoting after that would put a service already on its way
out back into the foreground. A null intent is the system
re-creating a START_STICKY service — not a foreground start, and
it needs nothing. */
if (intent != null) holdForeground()
it needs nothing.
ONLY FOR AN INTENT SOMEBODY ELSE SENT — a media key, a card
button — which is one that carries an action or a data uri.
media3 starts this service ITSELF on every foreground notification
update: `MediaNotificationManager.startForeground` calls
`ContextCompat.startForegroundService(startSelfIntent)`, and that
intent is a bare `Intent(context, MediaService::class.java)` with
nothing but a uid extra — no action, no data. Promoting on THAT
re-posted the notification, which made media3 start the service
again, which landed here again: a loop at the rate two binder
calls allow, about six a second, for as long as the service was
in the foreground — while a track played, and for the ten-minute
tail after it paused. That is the whole of 2026-09-18's storm:
"Package enqueue rate is 6.6 … Shedding" for over a minute,
"Too many PendingIntent created for uid" (media3 mints a fresh
one for the custom button on every rebuild), and twenty refused
foreground starts in two hundred milliseconds once the app was
in the background. The self-start needs nothing from us: media3
promotes in the same breath it sends it. */
if (intent != null && (intent.action != null || intent.data != null)) holdForeground()
return super.onStartCommand(intent, flags, startId)
}
@@ -1260,9 +1497,18 @@ object AutoNative {
* left in silence.
*
* A tapped track brings its album with it, so the record keeps playing
* without any JS alive to advance it.
* without any JS alive to advance it. `startMs` below zero means "where
* it was left" for the resume row and the top for everything else.
*/
external fun play(dataDir: String, mediaId: String): String
external fun play(dataDir: String, mediaId: String, startMs: Long): String
/** What would resume, and where, as JSON (`{"ok":false}` when nothing
* can). Reads resume.json and stats a few files; starts nothing. */
external fun resumeInfo(dataDir: String): String
/** A browse row as the PAGE understands it — `{"kind":"album","albumId":n}`
* and its siblings — for the warm hand-off. Two lookups at most. */
external fun describe(dataDir: String, mediaId: String): String
/** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */
@@ -63,6 +63,13 @@ class QueueItemArgs {
var artKey: String? = null
}
/** Why a row the car handed to the page did not play — the Rust
* `RemoteFailure`, camelCase. */
@InvokeArg
class RemoteFailureArgs {
var message: String? = null
}
/**
* The bridge between the WebView and the service that owns the session.
* `update` mirrors the track on the bar (and starts the service the first
@@ -80,19 +87,34 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
payload.put("position", position)
trigger("action", payload)
}
/* The car asking the APP to play something this process cannot
resolve. Setting this is also what tells the media service the app
is warm — it is null whenever there is no WebView. */
/* The car asking the APP to play a row — any row, while the page is
up and owns the queue. Setting this is also what tells the media
service the app is warm — it is null whenever there is no WebView. */
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
MediaHub.onRemote = { playlist, item ->
MediaHub.onPlay = { desc, startMs ->
val payload = JSObject()
payload.put("action", "playRemote")
payload.put("playlist", playlist)
payload.put("item", item)
payload.put("action", "playMediaId")
payload.put("desc", desc)
payload.put("startMs", startMs)
trigger("action", payload)
}
}
/** The page is going away — the app swiped out of recents, say — while
* the media service may keep this process alive for the car. From here
* there is nobody behind `trigger`, so the hand-off must stop believing
* the app is warm: a tap in the car would otherwise be posted to a
* WebView that no longer exists and answered by nothing. Null means the
* engine in this process plays what it can and the car is told about the
* rest, exactly as on a cold start. A new Activity builds a new plugin,
* whose init arms both again. */
override fun onDestroy() {
MediaHub.onPlay = null
MediaHub.onAction = null
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
super.onDestroy()
}
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java)
@@ -126,6 +148,15 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
invoke.resolve()
}
/** The page could not play a row the car handed it. The session says
* why, on the car screen, instead of leaving the driver in silence. */
@Command
fun remoteFailed(invoke: Invoke) {
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
invoke.resolve()
}
/** Android 13+: the card is a notification and needs the permission; asked
* once, the first time something plays. Media controls in the shade and
* on the lock screen follow the session either way. */
@@ -55,6 +55,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the state media3 reads ──────────────────────────────────────── */
override fun getState(): SimpleBasePlayer.State {
/* Something asked for could not play, and the reason is what the
car should show rather than a blank player or the last track's
card. A player error is only legal in STATE_IDLE, and media3's
legacy stub turns exactly that into PlaybackStateCompat's
STATE_ERROR with the message — which Android Auto draws as a card
over the browse list. The playlist is kept (if there was one) so
the transport does not lose its shape; the next update clears it. */
MediaHub.error?.let { why ->
val err = androidx.media3.common.PlaybackException(
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
)
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlayerError(err)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
}
val np = MediaHub.state
?: return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
@@ -179,8 +197,20 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
lock screen shows a grey hole. media3 decodes these bytes
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
is one BitmapFactory call this file no longer makes. */
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
val bytes = MediaHub.artBytes
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
if (bytes != null) {
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (remote != null) {
/* A streamed record's cover is a URL and no bytes ever arrive
for it on the headless path — there is no page to decode
one. media3's own loader fetches a URI, so the car gets
the sleeve and the colour it washes the screen with,
instead of the app icon's blue. */
md.setArtworkUri(Uri.parse(remote))
} else {
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
}
} else if (!artKey.isNullOrEmpty()) {
/* A LOCAL path only. A catalog record's art key is an http URL,
and media3's default loader would happily fetch it — twenty
@@ -256,7 +286,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
"SoulStream",
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
)
if (headless()) {
if (headless() || !MediaHub.warm()) {
/* the engine's own queue, or a page that is gone (the app swiped
away under live audio): pause and resume belong to the engine
either way, and a message to a WebView that no longer exists
is the button doing nothing */
AutoNative.transport(if (playWhenReady) "resume" else "pause")
return Futures.immediateVoidFuture()
}
@@ -312,8 +346,26 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
val id = mediaItems.getOrNull(i)?.mediaId
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
// The position media3 asks for: a resumption carries the saved one, a
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
// left", which only the resume row has an answer to.
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
return Futures.submit<Unit>(
{
// THE APP IS UP: the page owns the queue, so the tap is the
// page's — whatever the row is. Starting it on the engine
// underneath the page instead (which is what this did for a
// library album while the page had a queue of its own) left
// two drivers on one engine: the bar naming one track while
// another played, and the next boundary decided by whichever
// got there first. The page asks for focus itself when it
// plays, and reports back through remote_failed if it cannot.
if (MediaHub.warm()) {
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
MediaHub.play(desc, startMs)
return@submit
}
// FOCUS FIRST, and it is not optional. Without it the track
// opens into a port the framework keeps muted: the first run
// of this path showed a live AAudio track on our uid with
@@ -321,25 +373,27 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
// audible to nobody. On the WebView path the JS asks for
// focus; a car tapping a cold process has no JS to do it.
MediaHub.requestFocus(ctx.applicationContext)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
if (queued == 0) {
// Nothing local. If the app is up, its resolver can play
// this — a catalog row is not a broken row, it is one this
// process cannot do alone. If the app is NOT up there is
// nothing on the phone that could play it, and the focus
// goes back rather than being held for silence.
val remote = o.optBoolean("remote", false)
if (remote && MediaHub.warm()) {
android.util.Log.i("SoulStream", "auto hand-off to the app: $id")
MediaHub.remote(o.optLong("playlist"), o.optLong("item"))
// 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.
MediaHub.dropFocus(ctx.applicationContext)
if (o.optBoolean("remote", false)) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
MediaHub.fail(
ctx.applicationContext,
"This track streams from the network. Open SoulStream on your phone to play it here.",
)
} else if (id == "ss:resume") {
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
} else {
if (remote) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
}
MediaHub.dropFocus(ctx.applicationContext)
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
}
} else {
// Write the card ourselves. `getState()` mirrors
@@ -354,9 +408,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
np.artist = o.optString("artist").ifEmpty { null }
np.album = o.optString("album").ifEmpty { null }
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
np.position = 0.0
/* a resumed track starts where it was left; the engine's
own position takes over from the first tick */
np.position = o.optLong("positionMs", 0L) / 1000.0
np.playing = true
np.hasNext = queued > 1
np.hasNext = o.optBoolean("hasNext", queued > 1)
np.hasPrev = false
// The cover, which is also the colour: Android Auto washes
// the playback screen with whatever artwork it is handed,
@@ -379,6 +435,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
}
override fun handleStop(): ListenableFuture<*> {
if (headless() || !MediaHub.warm()) {
AutoNative.transport("stop")
return Futures.immediateVoidFuture()
}
MediaHub.act("stop")
return awaitUpdate()
}
@@ -420,7 +480,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
if (headless()) {
if (headless() || !MediaHub.warm()) {
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
AutoNative.transport("next")
+4 -1
View File
@@ -3,7 +3,10 @@
// event. They are commands like any other to the ACL: without them here the
// listener was refused ("media-session.registerListener not allowed") and
// the card's buttons went nowhere.
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
// `remote_failed` is the RUST command's name, which is what the ACL keys on
// (the invoke is `plugin:media-session|remote_failed`); the Kotlin side's
// `remoteFailed` is reached through run_mobile_plugin and never appears here.
const COMMANDS: &[&str] = &["update", "clear", "remote_failed", "registerListener", "removeListener"];
fn main() {
// Registers the command permissions (permissions/autogenerated) and, on
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remote-failed"
description = "Enables the remote_failed command without any pre-configured scope."
commands.allow = ["remote_failed"]
[[permission]]
identifier = "deny-remote-failed"
description = "Denies the remote_failed command without any pre-configured scope."
commands.deny = ["remote_failed"]
@@ -9,6 +9,7 @@ hear the card's buttons (`addPluginListener`).
- `allow-update`
- `allow-clear`
- `allow-remote-failed`
- `allow-registerListener`
- `allow-removeListener`
@@ -76,6 +77,32 @@ Denies the registerListener command without any pre-configured scope.
<tr>
<td>
`media-session:allow-remote-failed`
</td>
<td>
Enables the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-remote-failed`
</td>
<td>
Denies the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-removeListener`
</td>
@@ -7,4 +7,4 @@ transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
"""
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
permissions = ["allow-update", "allow-clear", "allow-remote-failed", "allow-registerListener", "allow-removeListener"]
@@ -318,6 +318,18 @@
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "allow-remote-failed",
"markdownDescription": "Enables the remote_failed command without any pre-configured scope."
},
{
"description": "Denies the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "deny-remote-failed",
"markdownDescription": "Denies the remote_failed command without any pre-configured scope."
},
{
"description": "Enables the removeListener command without any pre-configured scope.",
"type": "string",
@@ -343,10 +355,10 @@
"markdownDescription": "Denies the update command without any pre-configured scope."
},
{
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`"
}
]
}
+30 -1
View File
@@ -141,9 +141,38 @@ fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
}
}
/// What the page sends when a row the CAR handed it could not be played:
/// the resolver found nothing, or the network is signed out. The session
/// turns it into an error state the head unit can show, with the reason,
/// instead of the silence a driver was getting.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RemoteFailure {
pub message: String,
}
#[tauri::command]
fn remote_failed<R: Runtime>(app: tauri::AppHandle<R>, failure: RemoteFailure) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let session = app.state::<MediaSession<R>>();
session
.0
.run_mobile_plugin::<serde_json::Value>("remoteFailed", failure)
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, failure);
Err("the media session is a phone feature".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("media-session")
.invoke_handler(tauri::generate_handler![update, clear])
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
+350 -93
View File
@@ -18,6 +18,11 @@
//! Every failure path returns an empty-but-valid JSON array. A head unit that
//! is handed nothing draws an empty folder; a head unit that is left waiting
//! drops the app. Empty is always the better answer.
//!
//! Streamed playlist rows are the one place this reaches past the database:
//! a row whose remembered peer file (`stream_sources.rs`) is byte-complete in
//! the stream cache plays from here like a library track, because it IS a
//! file on this phone. Everything else streamed is handed to the app.
use jni::objects::{JObject, JString};
use jni::sys::jstring;
@@ -29,6 +34,11 @@ use std::path::{Path, PathBuf};
/// with a media id that happens to be a file path.
pub const ROOT: &str = "ss:root";
/// The root a host asks for with `EXTRA_RECENT`: Android's own "continue
/// where you left off" surfaces and Android Auto's resumption row. It holds
/// exactly one playable row, the resume point, or nothing.
pub const RECENT_ROOT: &str = "ss:recent-root";
/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists ·
/// Saved). Playlists is deliberately absent: the phone holds exactly one, and
/// an empty tab is a dead quarter of the only four root slots Auto gives us.
@@ -50,11 +60,10 @@ struct Node {
subtitle: String,
browsable: bool,
playable: bool,
/// Absolute path to a cover, when the row has one. Kotlin decides what a
/// Absolute path to a cover, or an https URL. Kotlin decides what a
/// path can become on the car's side; this layer only reports it.
art: Option<String>,
/// Absolute path to the audio file, for playable rows. Round fifty-one is
/// what does anything with it.
/// Absolute path to the audio file, for playable rows.
path: Option<String>,
}
@@ -264,7 +273,7 @@ fn saved(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
rows.collect()
}
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
fn recent(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
// RECENTLY PLAYED ALBUMS, not tracks. A car's "Recent" is "put that back
// on", and what a driver means by that is the record — a list of single
// songs plays one and stops.
@@ -274,8 +283,15 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
// that has since left the library drops out of the join, which is right
// for a row whose whole promise is that tapping it works.
let mut out: Vec<Node> = Vec::new();
// First of all: where playback was left. It is the row a driver wants
// most often, and it is the only one that knows a position.
out.extend(resume_row(data_dir));
// The count is the RECORD's tracks, not how many of them were played —
// "Daft Punk · 3 tracks" under a thirteen-track album was the played
// count leaking into the subtitle.
let mut stmt = conn.prepare(
"SELECT a.id, a.title, a.artist, a.art_path, COUNT(DISTINCT t.id), MAX(p.started_at)
"SELECT a.id, a.title, a.artist, a.art_path,
(SELECT COUNT(*) FROM tracks x WHERE x.album_id = a.id), MAX(p.started_at)
FROM plays p
JOIN tracks t
ON t.title = p.title COLLATE NOCASE
@@ -304,7 +320,7 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
out.extend(rows.flatten());
}
out.extend(playlists(conn).unwrap_or_default());
out.extend(playlists(conn, data_dir).unwrap_or_default());
// A library that has never been played is not an empty shelf. On a fresh
// install — or a phone whose history was wiped — "Recent" would otherwise
@@ -316,39 +332,89 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
Ok(out)
}
/// The one row the recent root holds: "continue", with the track that was
/// playing, where it was. Nothing when there is no resume point or none of
/// it can play from here.
fn resume_row(data_dir: &Path) -> Option<Node> {
let (tracks, idx, position, from) = crate::headless::resume_queue(data_dir)?;
let t = tracks.get(idx)?;
let mins = (position / 60.0).floor() as i64;
let secs = (position % 60.0).floor() as i64;
let at = if position > 0.5 { format!(" · {mins}:{secs:02}") } else { String::new() };
let source = if from.is_empty() { t.artist.clone() } else { from };
Some(Node {
id: crate::headless::RESUME_ID.to_owned(),
title: t.title.clone(),
subtitle: format!("Continue · {source}{at}"),
browsable: false,
playable: true,
art: t.art.clone(),
path: Some(t.path.to_string_lossy().into_owned()),
})
}
/// A playlist row's playable file from this process: its local path when it
/// has one, else the byte-complete cache copy of its remembered peer file.
fn row_file(
data_dir: &Path,
kind: &str,
path: Option<&str>,
track_id: Option<&str>,
artist: &str,
title: &str,
) -> Option<(PathBuf, Option<crate::resume::StreamRef>)> {
if kind == "local" {
return path.map(|p| (PathBuf::from(p), None));
}
if kind != "stream" {
return None;
}
let src = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title))?;
let file = crate::stream_sources::cached_file(data_dir, &src)?;
Some((
file,
Some(crate::resume::StreamRef {
username: src.username,
path: src.path,
size: src.size,
}),
))
}
/// Playlists, newest first, as browsable rows.
///
/// Counted out of `playlist_items` rather than `playlist_tracks`: it is the
/// richer table, and the one that knows whether an entry is a local file
/// (`kind`/`path`) or something that needs the resolver.
fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
/// (`kind`/`path`) or something that needs the resolver. A streamed entry
/// whose cache copy is whole counts as here.
fn playlists(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
let mut stmt = conn.prepare(
"SELECT p.id, p.name, COUNT(i.id),
SUM(CASE WHEN i.kind = 'local' AND i.path IS NOT NULL THEN 1 ELSE 0 END),
"SELECT p.id, p.name,
-- A streamed entry has no cover FILE, it has a remote URL.
-- Auto fetches an https artwork uri itself, so the row gets a
-- sleeve either way rather than a grey tile.
COALESCE(MAX(i.art_path), MAX(i.art_url))
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
GROUP BY p.id
(SELECT COALESCE(i.art_path, i.art_url) FROM playlist_items i
WHERE i.playlist_id = p.id AND COALESCE(i.art_path, i.art_url) IS NOT NULL
ORDER BY i.position LIMIT 1)
FROM playlists p
ORDER BY p.created_at DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([ROW_CAP as i64], |r| {
let id: i64 = r.get(0)?;
let name: String = r.get(1)?;
let n: i64 = r.get(2)?;
let local: i64 = r.get(3).unwrap_or(0);
let art: Option<String> = r.get(4)?;
let heads: Vec<(i64, String, Option<String>)> = stmt
.query_map([ROW_CAP as i64], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?;
let mut out = Vec::with_capacity(heads.len());
for (id, name, art) in heads {
let (n, here) = playlist_counts(conn, data_dir, id)?;
// Say plainly when part of a playlist cannot play here. A row that
// silently plays four of its nine tracks is worse than one that says
// so before it is tapped.
let sub = if local < n {
format!("Playlist · {} of {} here", local, n)
let sub = if here < n {
format!("Playlist · {} of {} here", here, n)
} else {
format!("Playlist · {}", plural(n, "track", "tracks"))
};
Ok(Node {
out.push(Node {
id: format!("ss:playlist:{id}"),
title: name,
subtitle: sub,
@@ -356,17 +422,44 @@ fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
playable: false,
art,
path: None,
})
})?;
rows.collect()
});
}
Ok(out)
}
/// A playlist's entries, in order. Only the local ones are playable; the rest
/// need the resolver, which does not exist in a process with no WebView, and
/// are shown rather than hidden so the list matches the phone's.
fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
/// (all rows, rows that play from this process) for one playlist.
fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<(i64, i64)> {
let mut stmt = conn.prepare(
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind
"SELECT kind, path, track_id, artist, title FROM playlist_items WHERE playlist_id = ?1",
)?;
let mut n = 0i64;
let mut here = 0i64;
let rows = stmt.query_map([playlist_id], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
))
})?;
for row in rows.flatten() {
n += 1;
let (kind, path, track_id, artist, title) = row;
if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).is_some() {
here += 1;
}
}
Ok((n, here))
}
/// A playlist's entries, in order. The local ones and the streamed ones whose
/// cache copy is whole are playable; the rest need the resolver, which does
/// not exist in a process with no WebView, and are shown rather than hidden
/// so the list matches the phone's.
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
let mut stmt = conn.prepare(
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id
FROM playlist_items
WHERE playlist_id = ?1
ORDER BY position",
@@ -379,20 +472,34 @@ fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<N
let path: Option<String> = r.get(4)?;
let art: Option<String> = r.get(5)?;
let kind: String = r.get(6)?;
let local = kind == "local" && path.is_some();
let sub = match (&album, local) {
(_, false) => format!("{artist} · on your phone"),
(Some(al), true) if !al.is_empty() => format!("{artist} · {al}"),
_ => artist.clone(),
let track_id: Option<String> = r.get(7)?;
let file = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
// Every real row is PLAYABLE, whether or not its file is here: a
// non-playable, non-browsable item is drawn by the host as a bare
// tile in a list of rows (the first head-unit run showed four rows
// and thirty-six tiles). The row that will need the app says so in
// its subtitle, and a tap on it is the hand-off (warm) or the error
// card (cold) — never a silent nothing. Kotlin marks the ones whose
// file is here with the host's own "downloaded" indicator.
let unresolved = kind == "unresolved";
let sub = if unresolved {
format!("{artist} · unresolved")
} else if file.is_none() {
format!("{artist} · needs the app")
} else {
match &album {
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
_ => artist.clone(),
}
};
Ok(Node {
id: format!("ss:pitem:{id}"),
title,
subtitle: sub,
browsable: false,
playable: local,
playable: !unresolved,
art,
path,
path: file.map(|(p, _)| p.to_string_lossy().into_owned()),
})
})?;
rows.collect()
@@ -418,13 +525,13 @@ fn track_node(r: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
/// The whole tree, dispatched on the parent id. Unknown ids answer with
/// nothing, which is what a head unit asking about a node we have since
/// stopped publishing should get.
fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
fn children(conn: &Connection, data_dir: &Path, parent: &str) -> rusqlite::Result<Vec<Node>> {
match parent {
ROOT => Ok(tabs()),
"ss:albums" => albums(conn),
"ss:artists" => artists(conn),
"ss:saved" => saved(conn),
"ss:recent" => recent(conn),
"ss:recent" => recent(conn, data_dir),
_ => {
if let Some(id) = parent.strip_prefix("ss:album:") {
match id.parse::<i64>() {
@@ -433,7 +540,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
}
} else if let Some(id) = parent.strip_prefix("ss:playlist:") {
match id.parse::<i64>() {
Ok(n) => playlist_items(conn, n),
Ok(n) => playlist_items(conn, data_dir, n),
Err(_) => Ok(Vec::new()),
}
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
@@ -448,7 +555,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
}
/// Turn a media id from the browse tree into something playable: the files, in
/// order, and which one the tap meant.
/// order, which one the tap meant, and the name of what it came from.
///
/// A tapped TRACK brings its whole album with it at the right index, because
/// "the album keeps going" is what a record player does and what a driver
@@ -461,9 +568,10 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
pub(crate) fn resolve_playable(
data_dir: &str,
media_id: &str,
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
) -> Option<(Vec<crate::headless::PlayTrack>, usize, String)> {
use crate::headless::PlayTrack;
let db = db_path(Path::new(data_dir));
let dir = Path::new(data_dir);
let db = db_path(dir);
let conn = match open_read_only(&db) {
Ok(c) => c,
Err(e) => {
@@ -471,7 +579,7 @@ pub(crate) fn resolve_playable(
return None;
}
};
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
let album_tracks = |album_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
let mut stmt = conn.prepare(
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
FROM tracks t JOIN albums a ON a.id = t.album_id
@@ -479,54 +587,84 @@ pub(crate) fn resolve_playable(
ORDER BY t.disc_no, t.track_no, t.title",
)?;
let rows = stmt.query_map([album_id], |r| {
let id = r.get::<_, i64>(0)?;
Ok((
r.get::<_, i64>(0)?,
id,
PlayTrack {
id: Some(id.to_string()),
path: PathBuf::from(r.get::<_, String>(1)?),
title: r.get(2)?,
artist: r.get(3)?,
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
album: r.get(5)?,
art: r.get(6)?,
stream: None,
},
))
})?;
rows.collect()
let rows: Vec<(i64, PlayTrack)> = rows.collect::<rusqlite::Result<_>>()?;
let from = rows.first().map(|(_, t)| t.album.clone()).unwrap_or_default();
Ok((rows, from))
};
if let Some(id) = media_id.strip_prefix("ss:album:") {
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
}
// A playlist plays its LOCAL entries, in order, skipping the ones that
// need the resolver. Silently dropping them is the right call here: the
// row that led here already said "4 of 9 here", so the gap was declared
// before the tap rather than discovered after it.
let playlist_local = |playlist_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
// A playlist plays the entries this process can: its local files and the
// streamed rows whose cache copy is whole, in order, skipping the ones
// that need the resolver. Silently dropping those is the right call
// here: the row that led here already said "4 of 9 here", so the gap was
// declared before the tap rather than discovered after it.
let playlist_here = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
let name: String = conn
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
.unwrap_or_default();
let mut stmt = conn.prepare(
"SELECT id, title, artist, album, duration_secs, path, art_path
"SELECT id, title, artist, album, duration_secs, path, COALESCE(art_path, art_url), kind, track_id
FROM playlist_items
WHERE playlist_id = ?1 AND kind = 'local' AND path IS NOT NULL
WHERE playlist_id = ?1
ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
Ok((
r.get::<_, i64>(0)?,
PlayTrack {
title: r.get(1)?,
artist: r.get(2)?,
album: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
path: PathBuf::from(r.get::<_, String>(5)?),
art: r.get(6)?,
},
))
let id: i64 = r.get(0)?;
let title: String = r.get(1)?;
let artist: String = r.get(2)?;
let album: Option<String> = r.get(3)?;
let duration = r.get::<_, Option<i64>>(4)?.map(|s| s as f64);
let path: Option<String> = r.get(5)?;
let art: Option<String> = r.get(6)?;
let kind: String = r.get(7)?;
let track_id: Option<String> = r.get(8)?;
Ok((id, title, artist, album, duration, path, art, kind, track_id))
})?;
rows.collect()
let mut out = Vec::new();
for row in rows.flatten() {
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
let Some((file, stream)) =
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title)
else {
continue;
};
out.push((
id,
PlayTrack {
id: Some(format!("pl{id}")),
path: file,
title,
artist,
album: album.unwrap_or_default(),
duration,
art,
stream,
},
));
}
Ok((out, name))
};
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
let rows = playlist_local(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
let (rows, from) = playlist_here(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
}
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
let item_id = id.parse::<i64>().ok()?;
@@ -537,9 +675,12 @@ pub(crate) fn resolve_playable(
|r| r.get(0),
)
.ok()?;
let rows = playlist_local(playlist_id).ok()?;
let idx = rows.iter().position(|(i, _)| *i == item_id).unwrap_or(0);
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
let (rows, from) = playlist_here(playlist_id).ok()?;
// The tapped row itself must be playable here, or the tap means a
// hand-off — starting the playlist from a different row than the one
// under the driver's finger would be a lie.
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
}
if let Some(id) = media_id.strip_prefix("ss:track:") {
let track_id = id.parse::<i64>().ok()?;
@@ -548,9 +689,9 @@ pub(crate) fn resolve_playable(
r.get(0)
})
.ok()?;
let rows = album_tracks(album_id).ok()?;
let (rows, from) = album_tracks(album_id).ok()?;
let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0);
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
}
None
}
@@ -639,9 +780,16 @@ fn browse_json(data_dir: &str, parent: &str) -> String {
if parent == ROOT {
return rows(&tabs());
}
let db = db_path(Path::new(data_dir));
let dir = Path::new(data_dir);
// Nor does the recent root: it is one row out of resume.json, and a
// host asking for it is asking "can you continue?", not "show me the
// library".
if parent == RECENT_ROOT {
return rows(&resume_row(dir).into_iter().collect::<Vec<_>>());
}
let db = db_path(dir);
let nodes = match open_read_only(&db) {
Ok(conn) => children(&conn, parent).unwrap_or_else(|e| {
Ok(conn) => children(&conn, dir, parent).unwrap_or_else(|e| {
warn(&format!("auto browse {parent}: {e}"));
Vec::new()
}),
@@ -663,7 +811,7 @@ fn rows(nodes: &[Node]) -> String {
/// A cold service process has never run Tauri, so nothing has installed an
/// `android_logger` and `log::warn!` goes nowhere — which is why the first
/// failure here was silent. Write to the platform log directly.
fn warn(msg: &str) {
pub(crate) fn warn(msg: &str) {
let tag = std::ffi::CString::new("SoulStream").unwrap_or_default();
if let Ok(m) = std::ffi::CString::new(msg) {
// SAFETY: both pointers are valid NUL-terminated strings for the call.
@@ -697,7 +845,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let par: Option<String> = env.get_string(&parent).ok().map(Into::into);
let out = match (dir, par) {
(Some(d), Some(p)) => browse_json(&d, &p),
(Some(d), Some(p)) => {
crate::headless::set_data_dir(Path::new(&d));
browse_json(&d, &p)
}
_ => "[]".to_owned(),
};
// Even the hand-back has to not panic across the boundary: an empty array
@@ -708,25 +859,28 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
/// a process that may have no Activity and no WebView.
/// `AutoNative.play(dataDir, mediaId, startMs)` — start playback from a
/// browse row, in a process that may have no Activity and no WebView.
/// `startMs` below zero means "where it was left" (the resume row) or the
/// top (everything else).
///
/// Returns how many tracks were queued, so Kotlin can answer the car with an
/// error state instead of silence when a row resolves to nothing. Catalog and
/// Soulseek rows are not in the tree yet and will resolve to nothing by
/// construction, which is the correct answer for them: they need the resolver,
/// which needs the WebView.
/// Soulseek rows with no cache copy resolve to nothing by construction, which
/// is the correct answer for them: they need the resolver, which needs the
/// WebView.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
media_id: JString,
start_ms: jni::sys::jlong,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
let out = match (dir, id) {
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m),
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m, start_ms),
_ => "{\"queued\":0}".to_owned(),
};
env.new_string(out)
@@ -735,6 +889,30 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.resumeInfo(dataDir)` — what would resume, and where, as one
/// JSON object; `{"ok":false}` when nothing can. Never starts anything:
/// Android's own resumption surfaces ask this to DRAW a card long before
/// anyone presses play on it.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_resumeInfo(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let out = match dir {
Some(d) => {
crate::headless::set_data_dir(Path::new(&d));
crate::headless::resume_info_json(Path::new(&d))
}
None => "{\"ok\":false}".to_owned(),
};
env.new_string(out)
.or_else(|_| env.new_string("{\"ok\":false}"))
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
/// same JSON shape as a browse.
#[no_mangle]
@@ -756,13 +934,88 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
.unwrap_or(std::ptr::null_mut())
}
/// What a media id IS, for the page.
///
/// While the app is up the page owns the queue — shuffle, repeat, failover,
/// the resolver, the session it restores — so a car's tap is handed to it
/// whole rather than started on the engine underneath it, which would leave
/// the bar naming one track while another played. The page cannot parse
/// our ids and should not: this turns one into the thing the page already
/// knows how to play, and the id stays a thing the Rust side minted.
pub(crate) fn describe_json(data_dir: &str, media_id: &str) -> String {
if media_id == crate::headless::RESUME_ID {
return serde_json::json!({ "kind": "resume" }).to_string();
}
if let Some(id) = media_id.strip_prefix("ss:album:") {
if let Ok(n) = id.parse::<i64>() {
return serde_json::json!({ "kind": "album", "albumId": n }).to_string();
}
}
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
if let Ok(n) = id.parse::<i64>() {
return serde_json::json!({ "kind": "playlist", "playlistId": n, "itemId": 0 }).to_string();
}
}
let conn = match open_read_only(&db_path(Path::new(data_dir))) {
Ok(c) => c,
Err(_) => return serde_json::json!({ "kind": "unknown" }).to_string(),
};
if let Some(id) = media_id.strip_prefix("ss:track:") {
if let Ok(track) = id.parse::<i64>() {
if let Ok(album) = conn.query_row(
"SELECT album_id FROM tracks WHERE id = ?1",
[track],
|r| r.get::<_, i64>(0),
) {
return serde_json::json!({ "kind": "track", "albumId": album, "trackId": track })
.to_string();
}
}
}
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
if let Ok(item) = id.parse::<i64>() {
if let Ok(pl) = conn.query_row(
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
[item],
|r| r.get::<_, i64>(0),
) {
return serde_json::json!({ "kind": "playlist", "playlistId": pl, "itemId": item })
.to_string();
}
}
}
serde_json::json!({ "kind": "unknown" }).to_string()
}
/// `AutoNative.describe(dataDir, mediaId)` — the page's view of a browse
/// row, for the warm hand-off. See [`describe_json`].
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_describe(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
media_id: JString,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
let out = match (dir, id) {
(Some(d), Some(m)) => describe_json(&d, &m),
_ => "{\"kind\":\"unknown\"}".to_owned(),
};
env.new_string(out)
.or_else(|_| env.new_string("{\"kind\":\"unknown\"}"))
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
/// What a row would need the APP for, when it cannot play from here.
///
/// A playlist entry with `kind='stream'` has no file on this phone — only a
/// catalog id — and turning that into audio is the resolver's job, which is
/// hundreds of lines of shared JS that exist only while a WebView does. So
/// this does not try. It reports WHICH playlist and entry were meant, and the
/// warm path hands that to the app, which already knows how to play it.
/// A playlist entry with `kind='stream'` and no whole cache copy has no file
/// on this phone — only a catalog id — and turning that into audio is the
/// resolver's job, which is hundreds of lines of shared JS that exist only
/// while a WebView does. So this does not try. It reports WHICH playlist and
/// entry were meant, and the warm path hands that to the app, which already
/// knows how to play it.
pub(crate) fn remote_ref(data_dir: &str, media_id: &str) -> Option<(i64, i64)> {
let conn = open_read_only(&db_path(Path::new(data_dir))).ok()?;
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
@@ -840,7 +1093,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jlong {
(crate::headless::audio(None).progress().0 * 1000.0) as jni::sys::jlong
match crate::headless::audio_if_up() {
Some(a) => (a.progress().0 * 1000.0) as jni::sys::jlong,
None => 0,
}
}
/// `AutoNative.sounding()` — whether the engine is actually playing, as
@@ -850,16 +1106,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_sounding(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jboolean {
u8::from(crate::headless::audio(None).progress().1)
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.progress().1))
}
/// `AutoNative.driving()` — true when the headless queue is what is playing,
/// so Kotlin can send a transport command to Rust instead of to a WebView
/// that may not exist.
/// that may not exist. Asked on every card rebuild, so it must never CREATE
/// the engine: a process with no engine is not driving.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jboolean {
u8::from(crate::headless::audio(None).driving())
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.driving()))
}
+304 -5
View File
@@ -15,13 +15,21 @@ use crate::state::AppState;
pub struct AppStatus {
pub version: &'static str,
pub connected_as: Option<String>,
/// A dropped session is being dialled back (state.rs `Reconnect`).
pub redialing: bool,
}
#[tauri::command]
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
let redialing = state
.reconnect
.lock()
.map(|r| r.dialing)
.unwrap_or(false);
Ok(AppStatus {
version: env!("CARGO_PKG_VERSION"),
connected_as: state.connected_as.lock().await.clone(),
redialing,
})
}
@@ -456,6 +464,32 @@ pub async fn connect_and_login(
password: String,
remember: bool,
) -> Result<String, String> {
// An explicit login is a new generation: whatever redial loop the last
// session's drop may have started stands down, and the old pump's own
// Disconnected (from the shutdown inside login_inner) schedules nothing.
let generation = {
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.generation += 1;
r.wanted = true;
r.attempt = 0;
r.dialing = false;
r.generation
};
login_inner(app, username, password, remember, generation).await
}
/// The login itself, shared by the user's own tap and the automatic redial
/// (`schedule_reconnect`). `generation` is the session this login belongs
/// to: the event pump it spawns carries it, and a drop reported by a pump
/// of an older generation redials nothing and says nothing.
pub(crate) async fn login_inner(
app: tauri::AppHandle,
username: String,
password: String,
remember: bool,
generation: u64,
) -> Result<String, String> {
let state = app.state::<AppState>();
// Drop any previous session first.
if let Some(old) = state.client.lock().await.take() {
old.shutdown();
@@ -533,6 +567,12 @@ pub async fn connect_and_login(
*state.client.lock().await = Some(client);
*state.connected_as.lock().await = Some(username.clone());
{
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.connected_at = crate::resume::now_secs();
r.dialing = false;
r.wanted = true;
}
// Forward client events to the webview for the life of this session.
let event_app = app.clone();
@@ -855,10 +895,45 @@ pub async fn connect_and_login(
}
}
Event::Disconnected { reason } => {
let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason });
// A pump outlives the session it served for a moment:
// login_inner shuts the old client down and its reader
// reports the drop AFTER the new session has logged in.
// Until 2026-09-18 that stale drop stamped the fresh
// session offline and cleared its username. Only the
// pump of the current generation may say "disconnected".
let (current, wanted) = event_app
.try_state::<AppState>()
.and_then(|st| st.reconnect.lock().ok().map(|r| (r.generation, r.wanted)))
.unwrap_or((generation, true));
if current != generation {
// Two ways to be stale: replaced by a newer login
// (say nothing — the page already heard "connected"
// from it), or ended by the user's own Disconnect,
// which the page still deserves to hear.
if !wanted {
let _ = event_app.emit(
"soulseek:state",
UiEvent::Disconnected {
reason: reason.clone(),
},
);
}
log::info!("soulseek: an older session's pump ended ({reason})");
break;
}
log::warn!("soulseek: disconnected ({reason})");
let _ = event_app.emit(
"soulseek:state",
UiEvent::Disconnected {
reason: reason.clone(),
},
);
if let Some(st) = event_app.try_state::<AppState>() {
*st.connected_as.lock().await = None;
}
// The drop belongs to THIS session: dial it back unless
// the user ended it on purpose.
schedule_reconnect(event_app.clone(), generation);
break;
}
Event::ExcludedSearchPhrases(p) => {
@@ -1033,6 +1108,209 @@ fn now_ms() -> u64 {
.unwrap_or(0)
}
/// The wait before redial number `attempt` (1-based), in seconds.
///
/// 5 · 15 · 45 · 120 · 300 · 300 …, each with up to a fifth of jitter so
/// two instances on one address never dial in step. The server drops the
/// handshake of anyone who logs in eight to ten times in quick succession
/// (paid for on 2026-08-03), and eight of these span a quarter of an hour.
fn redial_wait_secs(attempt: u32) -> u64 {
let base: u64 = match attempt {
0 | 1 => 5,
2 => 15,
3 => 45,
4 => 120,
_ => 300,
};
let jitter = (now_ms() % 1000) * base / 5000; // 0 ..= base/5
base + jitter
}
/// Dial the dropped session back, on a schedule the server tolerates.
///
/// One loop per generation: it stands down the moment the user connects or
/// disconnects by hand (either bumps the generation), the moment a login
/// lands (its own or anyone's), and when there are no stored credentials to
/// dial with. Every attempt is announced to the page as
/// `soulseek:state { kind: "reconnecting", attempt, in_secs }`, so the
/// network row can say "reconnecting…" instead of "offline".
///
/// The attempt counter is reset by a drop that FOLLOWED a stable session
/// (two minutes or more), and kept by a drop that followed a redial which
/// lasted seconds — so a link that flaps keeps backing off rather than
/// hammering.
pub(crate) fn schedule_reconnect(app: tauri::AppHandle, generation: u64) {
{
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
if !r.wanted || r.generation != generation || r.dialing {
return;
}
let now = crate::resume::now_secs();
if r.connected_at > 0 && now.saturating_sub(r.connected_at) >= 120 {
r.attempt = 0;
}
r.dialing = true;
}
tauri::async_runtime::spawn(async move {
loop {
let (attempt, wait) = {
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
if !r.wanted || r.generation != generation {
r.dialing = false;
return;
}
r.attempt += 1;
(r.attempt, redial_wait_secs(r.attempt))
};
log::info!("soulseek: redial {attempt} in {wait}s");
let _ = app.emit(
"soulseek:state",
serde_json::json!({ "kind": "reconnecting", "attempt": attempt, "in_secs": wait }),
);
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
{
let state = app.state::<AppState>();
let stale = {
let r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
!r.wanted || r.generation != generation
};
if stale || state.connected_as.lock().await.is_some() {
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.dialing = false;
return;
}
}
let cfg = config::load(&app);
let Some(password) = config::load_password(&cfg.username).filter(|_| !cfg.username.is_empty())
else {
// Nothing to dial with: the user signed in without
// remembering. Say so once and stop wanting.
log::warn!("soulseek: cannot redial, no stored credentials");
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.wanted = false;
r.dialing = false;
return;
};
match login_inner(app.clone(), cfg.username.clone(), password, false, generation).await {
Ok(_) => {
log::info!("soulseek: redial {attempt} succeeded");
return;
}
Err(e) => {
log::warn!("soulseek: redial {attempt} failed: {e}");
// login_inner cleared connected_as; the loop goes round.
}
}
}
});
}
/// DEBUG ONLY: sever the live server connection the way a network drop
/// would, without touching the user's intent — so the redial can be watched
/// from the page. A release build refuses.
#[tauri::command]
pub async fn debug_drop_connection(state: State<'_, AppState>) -> Result<(), String> {
#[cfg(debug_assertions)]
{
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
client.shutdown();
Ok(())
}
#[cfg(not(debug_assertions))]
{
let _ = state;
Err("debug builds only".into())
}
}
/// The page naming its queue for the resume point (`resume.rs`): the window
/// around the current track, the index, the position, and what the queue
/// was built from. A no-op off Android — the desktop restores from its own
/// session and nothing there starts the app without a page.
#[tauri::command]
pub fn playback_note_queue(
app: tauri::AppHandle,
items: Vec<crate::resume::ResumeItem>,
idx: usize,
position: f64,
from: Option<String>,
) -> Result<(), String> {
#[cfg(target_os = "android")]
{
let rp = crate::resume::ResumePoint {
items,
idx,
position: position.max(0.0),
from: from.unwrap_or_default(),
updated_at: crate::resume::now_secs(),
};
if let Ok(dir) = crate::paths::app_data(&app) {
crate::headless::set_data_dir(&dir);
}
// An empty queue is a no-op, never a clear: a page with nothing on
// its bar is the state of every boot before the session is restored,
// and a stale resume point costs a wrong "Continue" where a wiped one
// costs the feature (2026-09-18).
if rp.items.is_empty() {
return Ok(());
}
match crate::headless::audio_if_up() {
Some(audio) => audio.note_queue(rp),
None => {
if let Ok(dir) = crate::paths::app_data(&app) {
crate::resume::save(&dir, &rp);
}
}
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, items, idx, position, from);
Ok(())
}
}
/// The playlists kept offline (config.rs `offline_playlists`).
#[tauri::command]
pub fn playlist_offline_list(app: tauri::AppHandle) -> Vec<i64> {
config::load(&app).offline_playlists
}
/// Keep a playlist offline, or stop. The fetching itself is the page's
/// (src/mobile/offline.svelte.js) — this only records the wish.
#[tauri::command]
pub fn playlist_offline_set(app: tauri::AppHandle, id: i64, keep: bool) -> Vec<i64> {
let mut cfg = config::load(&app);
cfg.offline_playlists.retain(|p| *p != id);
if keep {
cfg.offline_playlists.push(id);
}
config::save(&app, &cfg);
cfg.offline_playlists
}
/// Where playback was left (`resume.rs`), for the page: a car's PLAY into a
/// warm app with nothing on the bar is handed to the page as "resume", and
/// the page builds its queue from this. `None` when there is nothing, and
/// always `None` off Android.
#[tauri::command]
pub fn playback_resume_point(app: tauri::AppHandle) -> Option<crate::resume::ResumePoint> {
#[cfg(target_os = "android")]
{
crate::paths::app_data(&app).ok().and_then(|d| crate::resume::load(&d))
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
None
}
}
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
serde_json::json!({
"username": u.username,
@@ -4073,6 +4351,14 @@ pub async fn catalog_album_info(
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
// The user's own choice: the redial stands down and the pump's coming
// Disconnected is an older generation's, which schedules nothing.
{
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.wanted = false;
r.generation += 1;
r.dialing = false;
}
if let Some(client) = state.client.lock().await.take() {
client.shutdown();
}
@@ -5317,12 +5603,25 @@ pub async fn waveform(
/// under the words "Nothing playing". This is the boot-time answer, the same
/// shape of recovery `app_status` already provides for the Soulseek connection.
#[tauri::command]
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
players
pub fn player_state(players: State<'_, PlayerState>) -> PlayerStateReply {
let snapshot = players
.last_transport
.lock()
.map(|snapshot| snapshot.clone())
.unwrap_or_default()
.unwrap_or_default();
// Taken, not read: the page that asks first owns the handover, and a
// reload later must not restore a car queue that is long gone.
let handover = players.handover.lock().ok().and_then(|mut h| h.take());
PlayerStateReply { snapshot, handover }
}
/// The transport, plus — once, on Android, after a car started the engine —
/// the queue it was driving (`playback.rs` `handover`).
#[derive(Serialize)]
pub struct PlayerStateReply {
#[serde(flatten)]
pub snapshot: crate::playback::PlaybackSnapshot,
pub handover: Option<crate::resume::ResumePoint>,
}
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
@@ -5502,7 +5801,7 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
type DwmSet =
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
unsafe {
let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr());
let dwm = LoadLibraryA(b"dwmapi.dll\0".as_ptr());
if dwm.is_null() {
return;
}
+6
View File
@@ -144,6 +144,12 @@ pub struct AppConfig {
/// base surfaces thinned in CSS so the desktop glows through. None → false.
#[serde(default)]
pub glass_mode: Option<bool>,
/// Playlists the phone keeps offline: every streamed row fetched into the
/// stream cache in the background and pinned there, so the whole list
/// plays with the app shut — in a car, in a pocket. Ids from
/// `playlists.id`; a deleted playlist's id is harmless here.
#[serde(default)]
pub offline_playlists: Vec<i64>,
/// The Soulseek hub's buddy list — usernames watched on every login so
/// their status and stats stream in while connected.
#[serde(default)]
+297 -38
View File
@@ -19,13 +19,19 @@
//!
//! The queue here is deliberately NOT the app's queue. The JS one keeps
//! shuffle, repeat, `alts` failover, provenance and the three-ahead preload;
//! this is a `Vec<PathBuf>` and an index whose only job is that the album keeps
//! this is a `Vec` and an index whose only job is that the album keeps
//! playing when there is no JS alive to advance it.
//!
//! The pump is also the one place that always knows where the engine is, in
//! both modes, so it is what keeps the **resume point** (`resume.rs`) honest:
//! the page names the queue, the pump writes the position.
use crate::resume::{ResumeItem, ResumePoint, StreamRef};
use player::{Player, PlayerEvent};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub struct Audio {
pub player: Player,
@@ -42,31 +48,64 @@ pub struct Audio {
/// that stale zero: pausing looked like the track restarting, and a seek
/// that really happened showed as nothing at all.
progress: Mutex<(f64, bool)>,
/// What would resume if the process were started cold right now — the
/// queue as the page (or this module) last described it, with the
/// position the pump keeps current. `None` until somebody names a queue.
resume: Mutex<Option<ResumePoint>>,
/// When the resume file was last written; the pump flushes the position
/// at most every few seconds while playing, and at once on a pause.
flushed: Mutex<Instant>,
}
/// Enough of a track to play it and to describe it on a card, read once when
/// the queue is built so that advancing never needs the database again.
#[derive(Clone)]
pub struct PlayTrack {
/// The page's own id for the entry, so a handover keeps the identity —
/// a library track's number, `pl<n>` for a playlist row.
pub id: Option<String>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album: String,
pub duration: Option<f64>,
/// Absolute path to the album's cover. The card is built from this, and
/// so is the colour Android Auto washes the playback screen with — it
/// extracts from whatever artwork it is given, which is why supplying the
/// app icon tinted every record the same blue.
/// Absolute path to the album's cover, or an https URL for a streamed
/// record. The card is built from this, and so is the colour Android Auto
/// washes the playback screen with — it extracts from whatever artwork it
/// is given, which is why supplying the app icon tinted every record the
/// same blue.
pub art: Option<String>,
/// The peer file this is a cache copy of, when it is one. Carried into
/// the resume point so a later cold start can check the cache again
/// rather than trust a path the pruner may have taken since.
pub stream: Option<StreamRef>,
}
#[derive(Default)]
struct Queue {
items: Vec<PlayTrack>,
idx: usize,
/// The record's or playlist's name, for the resume point.
from: String,
}
static AUDIO: OnceLock<Arc<Audio>> = OnceLock::new();
/// Where this instance keeps its data. Set by whoever gets here first — the
/// car's tap hands it down from Kotlin, the app's own init from Tauri — and
/// the same directory either way.
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// How often the position is written while playing. Five seconds is the
/// most a resumed track can be behind where it really was.
const FLUSH_EVERY: Duration = Duration::from_secs(5);
pub fn set_data_dir(dir: &Path) {
let _ = DATA_DIR.set(dir.to_path_buf());
}
fn data_dir() -> Option<PathBuf> {
DATA_DIR.get().cloned()
}
/// The process's one engine, created on first use.
///
@@ -82,6 +121,8 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
subscriber: Mutex::new(None),
queue: Mutex::new(Queue::default()),
progress: Mutex::new((0.0, false)),
resume: Mutex::new(data_dir().as_deref().and_then(crate::resume::load)),
flushed: Mutex::new(Instant::now()),
});
let pump = Arc::clone(&audio);
let _ = std::thread::Builder::new()
@@ -116,6 +157,16 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
_ => {}
}
}
// The resume point follows the engine: the position on
// a schedule, the rest at once.
match &ev {
PlayerEvent::Position { secs, .. } => pump.note_position(*secs, false),
PlayerEvent::Paused | PlayerEvent::Stopped => {
let pos = pump.progress.lock().unwrap_or_else(|e| e.into_inner()).0;
pump.note_position(pos, true);
}
_ => {}
}
let sub = pump.subscriber.lock().unwrap_or_else(|e| e.into_inner());
if let Some(tx) = sub.as_ref() {
// A closed channel means the WebView went away;
@@ -129,6 +180,12 @@ pub fn audio(device: Option<String>) -> Arc<Audio> {
.clone()
}
/// The engine if one has been created, without creating one. A JNI call
/// asking "are you driving?" must not open an output stream to answer no.
pub fn audio_if_up() -> Option<Arc<Audio>> {
AUDIO.get().cloned()
}
impl Audio {
/// Point the pump at the WebView's forwarder. Replaces any previous one,
/// which is what a reload should do.
@@ -161,6 +218,7 @@ impl Audio {
match next {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
self.note_queue_from_engine();
// Tell Kotlin, or the card sits on track one for the rest of
// the album while the right audio plays under it.
notify_track(&t, more);
@@ -172,17 +230,20 @@ impl Audio {
}
}
fn start(&self, items: Vec<PlayTrack>, idx: usize) {
fn start(&self, items: Vec<PlayTrack>, idx: usize, from: &str, at_secs: Option<f64>) {
let first = items.get(idx).cloned();
{
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.items = items;
q.idx = idx;
q.from = from.to_owned();
}
match first {
Some(t) => {
self.player.play_local(t.path, t.duration);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
self.player.play_local_from(t.path, t.duration, at_secs);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) =
(at_secs.unwrap_or(0.0), true);
self.note_queue_from_engine();
}
None => {}
}
@@ -199,27 +260,31 @@ impl Audio {
/// this decides the order of an album, not anything anyone should be able
/// to bet on, and a dependency for it would be the wrong trade.
pub fn shuffle_rest(&self) -> usize {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let start = q.idx + 1;
let len = q.items.len();
if len <= start + 1 {
return 0;
}
let mut s = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mut i = len;
while i > start + 1 {
i -= 1;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let j = start + (s % ((i - start + 1) as u64)) as usize;
q.items.swap(i, j);
}
len - start
let moved = {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let start = q.idx + 1;
let len = q.items.len();
if len <= start + 1 {
return 0;
}
let mut s = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mut i = len;
while i > start + 1 {
i -= 1;
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let j = start + (s % ((i - start + 1) as u64)) as usize;
q.items.swap(i, j);
}
len - start
};
self.note_queue_from_engine();
moved
}
/// Seek, and move the reported position with it. Waiting for the engine's
@@ -228,6 +293,7 @@ impl Audio {
pub fn seek(&self, secs: f64) {
self.player.seek(secs);
self.progress.lock().unwrap_or_else(|e| e.into_inner()).0 = secs;
self.note_position(secs, true);
}
/// Move by one track in the fallback queue. Returns false at either end,
@@ -260,6 +326,7 @@ impl Audio {
Some(t) => {
self.player.play_local(t.path.clone(), t.duration);
*self.progress.lock().unwrap_or_else(|e| e.into_inner()) = (0.0, true);
self.note_queue_from_engine();
notify_track(&t, more);
true
}
@@ -286,6 +353,89 @@ impl Audio {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
q.items.get(q.idx).cloned()
}
/* ── the resume point ────────────────────────────────────────────── */
/// The page named its queue (or re-named it: a track change, a queue
/// edit, the ten-second write). Replaces what this engine knew and
/// writes the file. The position the page sends is taken as-is — it is
/// the page's read of the same engine, and a pump tick will correct it
/// within a second anyway.
pub fn note_queue(&self, rp: ResumePoint) {
*self.resume.lock().unwrap_or_else(|e| e.into_inner()) = Some(rp.clone());
if let Some(dir) = data_dir() {
crate::resume::save(&dir, &rp);
*self.flushed.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
}
}
/// The headless queue as the resume point: a car started or moved it,
/// and there is no page to write one.
fn note_queue_from_engine(&self) {
let rp = {
let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if q.items.is_empty() {
return;
}
let lo = q.idx.saturating_sub(crate::resume::WINDOW);
let hi = (q.idx + crate::resume::WINDOW + 1).min(q.items.len());
ResumePoint {
items: q.items[lo..hi].iter().map(resume_item).collect(),
idx: q.idx - lo,
position: self.progress.lock().unwrap_or_else(|e| e.into_inner()).0,
from: q.from.clone(),
updated_at: crate::resume::now_secs(),
}
};
self.note_queue(rp);
}
/// The pump's position tick. Written through on a pause, a seek or a
/// stop; otherwise at most every [`FLUSH_EVERY`] — a phone in a pocket
/// does not need a file write five times a second.
fn note_position(&self, secs: f64, now: bool) {
let rp = {
let mut guard = self.resume.lock().unwrap_or_else(|e| e.into_inner());
let Some(rp) = guard.as_mut() else {
return;
};
rp.position = secs;
rp.updated_at = crate::resume::now_secs();
rp.clone()
};
let due = {
let mut f = self.flushed.lock().unwrap_or_else(|e| e.into_inner());
if now || f.elapsed() >= FLUSH_EVERY {
*f = Instant::now();
true
} else {
false
}
};
if due {
if let Some(dir) = data_dir() {
crate::resume::save(&dir, &rp);
}
}
}
/// The resume point as this engine holds it.
pub fn resume_point(&self) -> Option<ResumePoint> {
self.resume.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
fn resume_item(t: &PlayTrack) -> ResumeItem {
ResumeItem {
id: t.id.clone(),
title: t.title.clone(),
artist: t.artist.clone(),
album: t.album.clone(),
duration: t.duration,
art: t.art.clone(),
path: Some(t.path.to_string_lossy().into_owned()),
stream: t.stream.clone(),
}
}
/// Hand a track change up to Kotlin so the card can follow the fallback queue.
@@ -339,13 +489,108 @@ fn notify_track(t: &PlayTrack, has_next: bool) {
}
}
/// The media id a resumed session is played under. The recent root lists
/// one row with this id; media3's resumption hands the same id back through
/// `handleSetMediaItems`; both land in [`play_media_id`].
pub const RESUME_ID: &str = "ss:resume";
/// The resume point turned into something the engine can play NOW: every
/// entry whose file is here (a library track, or a stream whose cache is
/// byte-complete), in order, with the index moved to the nearest playable
/// entry at or after the one that was playing. `None` when nothing in it
/// can play from cold.
///
/// Returns the tracks, the index, and the position to start at — zero when
/// the track that was playing is not the one being started, because
/// resuming three minutes into a DIFFERENT song is not resuming.
pub fn resume_queue(data_dir: &Path) -> Option<(Vec<PlayTrack>, usize, f64, String)> {
let rp = crate::resume::load(data_dir)?;
let mut tracks: Vec<PlayTrack> = Vec::new();
// the playable index each original entry landed at, or None
let mut landed: Vec<Option<usize>> = Vec::with_capacity(rp.items.len());
for it in &rp.items {
let path = it
.path
.as_deref()
.map(PathBuf::from)
.filter(|p| p.is_file())
.or_else(|| {
let s = it.stream.as_ref()?;
crate::stream_sources::cached_file(
data_dir,
&crate::stream_sources::StreamSource {
username: s.username.clone(),
path: s.path.clone(),
size: s.size,
duration: it.duration,
keep: false,
},
)
});
match path {
Some(p) => {
landed.push(Some(tracks.len()));
tracks.push(PlayTrack {
id: it.id.clone(),
path: p,
title: it.title.clone(),
artist: it.artist.clone(),
album: it.album.clone(),
duration: it.duration,
art: it.art.clone(),
stream: it.stream.clone(),
});
}
None => landed.push(None),
}
}
if tracks.is_empty() {
return None;
}
// the entry that was playing, else the first playable one after it,
// else the first playable one at all
let exact = landed.get(rp.idx).copied().flatten();
let idx = exact
.or_else(|| landed.iter().skip(rp.idx).find_map(|l| *l))
.unwrap_or(0);
let position = if exact.is_some() { rp.position.max(0.0) } else { 0.0 };
Some((tracks, idx, position, rp.from))
}
/// The resume row as the car's recent root and media3's resumption want it:
/// what would play, and where. `{"ok":false}` when nothing can.
pub fn resume_info_json(data_dir: &Path) -> String {
match resume_queue(data_dir) {
Some((tracks, idx, position, from)) => {
let t = &tracks[idx];
serde_json::json!({
"ok": true,
"id": RESUME_ID,
"title": t.title,
"artist": t.artist,
"album": t.album,
"art": t.art,
"duration": t.duration,
"positionMs": (position * 1000.0) as i64,
"from": from,
"queued": tracks.len(),
})
.to_string()
}
None => "{\"ok\":false}".to_owned(),
}
}
/// Play what a car tapped: the media id from the browse tree, resolved against
/// the library read-only, with the rest of its album behind it so the record
/// keeps going.
/// keeps going. `start_ms` below zero means "wherever this row was left" —
/// only the resume row has an answer to that; every other row starts at the
/// top.
///
/// Returns the number of tracks queued; 0 means nothing playable was found,
/// which the caller turns into an error state on the car rather than silence.
pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
pub fn play_media_id(data_dir: &str, media_id: &str, start_ms: i64) -> String {
set_data_dir(Path::new(data_dir));
// Nothing local: say whether the APP could play it, and which row was
// meant. A catalog entry is not a failure, it is a thing this process
// cannot do — and the difference decides between an error on the car and
@@ -362,15 +607,27 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
None => "{\"queued\":0}".to_owned(),
}
};
let Some((items, idx)) = crate::auto_browse::resolve_playable(data_dir, media_id) else {
return needs_app();
let (items, idx, at_secs, from) = if media_id == RESUME_ID {
match resume_queue(Path::new(data_dir)) {
Some((items, idx, saved, from)) => {
let at = if start_ms >= 0 { start_ms as f64 / 1000.0 } else { saved };
(items, idx, Some(at).filter(|s| *s > 0.5), from)
}
None => return "{\"queued\":0}".to_owned(),
}
} else {
let Some((items, idx, from)) = crate::auto_browse::resolve_playable(data_dir, media_id) else {
return needs_app();
};
if items.is_empty() {
return needs_app();
}
let at = if start_ms > 0 { Some(start_ms as f64 / 1000.0) } else { None };
(items, idx, at, from)
};
if items.is_empty() {
return needs_app();
}
let n = items.len();
let audio = audio(None);
audio.start(items, idx);
audio.start(items, idx, &from, at_secs);
match audio.now_playing() {
Some(t) => serde_json::json!({
"queued": n,
@@ -379,6 +636,8 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
"album": t.album,
"duration": t.duration,
"art": t.art,
"positionMs": (at_secs.unwrap_or(0.0) * 1000.0) as i64,
"hasNext": idx + 1 < n,
})
.to_string(),
None => format!("{{\"queued\":{n}}}"),
+13
View File
@@ -36,6 +36,12 @@ mod lyrics;
mod paths;
mod pipeline;
mod playback;
/// Where playback was, on disk, for a process with no page to ask: media
/// resumption, a head unit's PLAY into a cold app, Android Auto's autoplay.
mod resume;
/// The remembered peer file of every streamed track, mirrored to disk so the
/// cold browse can tell which cache files are whole playlist rows.
mod stream_sources;
mod profile;
mod playlist_formats;
mod playlists;
@@ -355,6 +361,13 @@ pub fn run() {
commands::preload_cached_stream,
commands::stream_waveform,
commands::stream_cache_ready,
commands::playback_note_queue,
commands::playback_resume_point,
commands::playlist_offline_list,
commands::playlist_offline_set,
commands::debug_drop_connection,
stream_sources::stream_source_remember,
stream_sources::stream_source_keep,
freenet::freenet_status,
freenet::freenet_set_enabled,
freenet::freenet_detect,
+45 -6
View File
@@ -157,6 +157,13 @@ pub struct PlayerState {
/// "what is the audio thread doing right now", mirrored here as the events
/// go past and read back by `player_state` at boot.
pub last_transport: Mutex<PlaybackSnapshot>,
/// The queue a CAR was driving when the app came up over it (Android
/// only; always `None` elsewhere). `init` takes it off the headless
/// engine at the moment the page takes the queue over, and the page's
/// `restoreSession` builds its queue from it instead of from its own
/// localStorage — which would describe an older session and put the
/// wrong title over the right audio. Read once through `player_state`.
pub handover: Mutex<Option<crate::resume::ResumePoint>>,
}
/// What the audio thread is doing, as of its last event. Deliberately only
@@ -201,16 +208,28 @@ pub fn init(app: &tauri::AppHandle) {
plays next and two things advancing one engine would skip a track each
time. Nothing about the desktop changes. */
#[cfg(target_os = "android")]
let (player, events) = {
let (player, events, handover) = {
if let Ok(dir) = crate::paths::app_data(app) {
crate::headless::set_data_dir(&dir);
}
let audio = crate::headless::audio(output_device.clone());
let (tx, rx) = std::sync::mpsc::channel();
audio.subscribe(tx);
// If a car was driving this engine, the page is about to take over a
// queue it has never heard of. Hand it the queue BEFORE clearing it,
// so the page can pick it up where it is rather than restoring an
// older session of its own over live audio.
let handover = if audio.driving() { audio.resume_point() } else { None };
audio.clear_queue();
(audio.player.clone(), rx)
(audio.player.clone(), rx, handover)
};
#[cfg(not(target_os = "android"))]
let (player, events) = Player::spawn_with_device(output_device);
let (player, events, handover) = {
let (p, e) = Player::spawn_with_device(output_device);
(p, e, None)
};
app.manage(PlayerState {
handover: Mutex::new(handover),
player,
streams: Mutex::new(HashMap::new()),
stream_caches: Mutex::new(HashMap::new()),
@@ -369,11 +388,23 @@ impl PlayerState {
}
}
/// The stream cache under an instance's data directory. Pure, so a process
/// that has no `AppHandle` (the media service, cold) reaches the same
/// files the app does.
pub fn cache_dir_in(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("stream-cache")
}
/// Stable cache filename for a remote file: short hash + original name.
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
let dir = crate::paths::app_data(app).ok()?.join("stream-cache");
let dir = cache_dir_in(&crate::paths::app_data(app).ok()?);
std::fs::create_dir_all(&dir).ok()?;
Some(cache_file_in(&dir, username, remote_path))
}
/// The same name, computed without touching the disk. `stream_sources.rs`
/// uses it to ask whether a remembered peer file is already here.
pub fn cache_file_in(dir: &std::path::Path, username: &str, remote_path: &str) -> PathBuf {
let mut hash: u64 = 0xcbf29ce484222325;
for b in username.bytes().chain(remote_path.bytes()) {
hash ^= b as u64;
@@ -388,7 +419,7 @@ pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) ->
c => c,
})
.collect();
Some(dir.join(format!("{hash:016x} {safe}")))
dir.join(format!("{hash:016x} {safe}"))
}
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
@@ -421,12 +452,17 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// a transfer buffer — age expiry off, size cap still applies.
let age_prune = !crate::config::stream_keep_forever(&cfg);
let Ok(dir) = crate::paths::app_data(app).map(|d| d.join("stream-cache")) else {
let Ok(data_dir) = crate::paths::app_data(app) else {
return;
};
let dir = cache_dir_in(&data_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// A track fetched because its playlist is kept offline is not a cache
// entry, it is the offline copy — the retention window and the size cap
// both leave it alone, or "keep offline" would quietly stop being true.
let kept = crate::stream_sources::kept_cache_files(&data_dir);
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
@@ -440,6 +476,9 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// entries. Deleting one can make its final rename fail.
return None;
}
if kept.contains(&path) {
return None;
}
let md = e.metadata().ok()?;
if !md.is_file() {
return None;
+143
View File
@@ -0,0 +1,143 @@
//! Where playback was — on disk, in a shape a process with no page can
//! turn back into music.
//!
//! The phone's player keeps its session in the WebView's localStorage: the
//! queue, the index, the position. That is the right home for the page's
//! own restart, and the wrong one for everything else that can ask "what
//! was playing?": Android's own media resumption (the card in Quick
//! Settings after a reboot), a Bluetooth head unit sending PLAY to an app
//! that is not running, and Android Auto's autoplay on connection, which
//! is exactly the same media-button PLAY arriving through media3's
//! `MediaButtonReceiver` into a cold `MediaService`. None of those can
//! read localStorage. All of them read this.
//!
//! Two writers, one file:
//! · the page, through `playback_note_queue`, whenever it writes its own
//! session — a track change, a queue edit, every ten seconds while
//! playing, and on its way to the background;
//! · the engine's own pump (`headless.rs` on Android), which knows the
//! true position at all times and the whole queue when it is the one
//! driving (a car started the process and there is no page).
//!
//! An entry names what it needs to play again with nothing else alive: a
//! local file, or the peer file whose bytes may already be in the stream
//! cache. Resolving that is the reader's job (`headless::resume_queue`),
//! because whether a cache file is complete is only knowable at the moment
//! of asking.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The peer identity of a streamed entry, enough to find its cache file.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamRef {
pub username: String,
pub path: String,
pub size: u64,
}
/// One queue entry as the page (or the headless queue) described it.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumeItem {
/// The page's own id for the entry (a library track id, `cat:<n>`,
/// `pl<n>`), carried back so a handover keeps the identity.
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub album: String,
#[serde(default)]
pub duration: Option<f64>,
/// A cover: a local path, or an https URL for a catalog record.
#[serde(default)]
pub art: Option<String>,
/// A file on this device — a library track, or a stream that was
/// already served from its cache file.
#[serde(default)]
pub path: Option<String>,
/// A peer file; plays cold only when its cache is byte-complete.
#[serde(default)]
pub stream: Option<StreamRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumePoint {
#[serde(default)]
pub items: Vec<ResumeItem>,
#[serde(default)]
pub idx: usize,
/// Seconds into `items[idx]`.
#[serde(default)]
pub position: f64,
/// What the queue was built from — the record's or playlist's name —
/// for the card's kicker and the car's "recent" row.
#[serde(default)]
pub from: String,
/// Unix seconds of the last write.
#[serde(default)]
pub updated_at: u64,
}
/// The page sends at most this many entries either side of the current
/// one. A two-thousand-track queue is not a thing to write every ten
/// seconds, and a resumed session that runs out after fifty tracks has
/// resumed.
pub const WINDOW: usize = 25;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("resume.json")
}
pub fn load(data_dir: &Path) -> Option<ResumePoint> {
let s = std::fs::read_to_string(file(data_dir)).ok()?;
let rp: ResumePoint = serde_json::from_str(&s).ok()?;
if rp.items.is_empty() {
return None;
}
Some(rp)
}
pub fn save(data_dir: &Path, rp: &ResumePoint) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(rp) {
// Beside-and-rename, so a cold reader never sees half a file.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_and_empty_reads_as_none() {
let d = std::env::temp_dir().join(format!("ss-resume-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
assert!(load(&d).is_none());
let rp = ResumePoint {
items: vec![ResumeItem { title: "One".into(), path: Some("/a.flac".into()), ..Default::default() }],
idx: 0,
position: 12.5,
from: "Discovery".into(),
updated_at: 1,
};
save(&d, &rp);
assert_eq!(load(&d), Some(rp));
save(&d, &ResumePoint::default());
assert!(load(&d).is_none(), "an empty queue is no resume point");
}
}
+43
View File
@@ -9,6 +9,9 @@ pub struct AppState {
/// never routes it — so the resolver strips these terms up front instead
/// of discovering the silence by timeout. Lowercased on receipt.
pub excluded_search_phrases: Mutex<Vec<String>>,
/// The session the user asked for, kept so a drop can be dialled back.
/// See [`Reconnect`] and `commands::schedule_reconnect`.
pub reconnect: std::sync::Mutex<Reconnect>,
/// The experimental Freenet layer's one handle (default: disabled, inert).
/// The privilege boundary: node operations never leave the Rust side.
pub freenet: freenet_adapter::FreenetAdapter,
@@ -16,3 +19,43 @@ pub struct AppState {
/// started by hand is not here — the adapter finds it all the same.
pub freenet_node: std::sync::Mutex<Option<std::process::Child>>,
}
/// What the app remembers about the session the user wanted, so that losing
/// it is followed by getting it back rather than by a grey "offline" the
/// user has to notice and tap through.
///
/// A Soulseek server connection dies for reasons that have nothing to do
/// with the user: 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 2026-09-18 every one
/// of those ended the same way — `Event::Disconnected`, the page stamped
/// offline, and every streamed track after that refused with "sign in on
/// Community" until somebody opened the login card again. On a phone in a
/// car that "somebody" does not exist.
///
/// So the backend keeps dialling, with a backoff the server tolerates. The
/// throttle is real and was paid for on 2026-08-03: eight to ten rapid
/// logins from one address and the server silently drops the handshake, and
/// every attempt during that silence reads as "timed out" and extends it.
/// The schedule is 5 s, 15 s, 45 s, 2 min, then 5 min for as long as it
/// takes, with jitter — eight attempts span a quarter of an hour, which is
/// well under the rate that trips it.
#[derive(Default)]
pub struct Reconnect {
/// True from a successful login until the user disconnects on purpose.
/// The only thing that makes a drop worth redialling.
pub wanted: bool,
/// Bumped by every explicit connect and disconnect. A redial loop, and
/// the event pump of the session it belongs to, both carry the
/// generation they were born with and stand down when it moves — which
/// is how a manual re-login never races the automatic one.
pub generation: u64,
/// Consecutive redials since the last session that stayed up. Reset by a
/// drop that follows a stable session, kept by a drop that follows a
/// redial that lasted seconds — so a flapping link keeps backing off.
pub attempt: u32,
/// When the current (or last) session logged in, unix seconds.
pub connected_at: u64,
/// A redial loop is running for this generation.
pub dialing: bool,
}
+309
View File
@@ -0,0 +1,309 @@
//! The remembered peer copy of every streamed track — on disk, where a
//! process with no WebView can read it.
//!
//! The phone's resolver remembers which peer file it last played for a
//! catalog track (`src/lib/sources.js`, `soulstream.streamSources.v1`), and
//! that memory is what lets a track play again from the stream cache with no
//! search at all. It lived in the WebView's localStorage, which meant it
//! existed only while a WebView did: Android Auto starting the app cold —
//! no Activity, no page — could see the cache files sitting in
//! `stream-cache/` and had no way to tell which playlist row any of them
//! belonged to. Every streamed row was "needs the app", even the ones whose
//! bytes were all there.
//!
//! This is the same memory, mirrored to `<data_dir>/stream-sources.json` by
//! the page every time it remembers a source, and read back here by the
//! headless browse tree and the cold player. The page stays the writer of
//! record for its own store; this file is a second copy that the Rust side
//! owns, and the two are keyed identically so a row found by either agrees
//! with the other.
//!
//! Also the home of `keep`: a source fetched because its playlist is kept
//! offline is marked, and `playback::prune_cache` leaves that file alone
//! however old it gets. Without it the retention window (thirty days
//! unplayed, two gigabytes) would quietly undo an offline playlist.
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// One remembered peer file. `size` is the peer-stated size, which is also
/// how a byte-complete cache file is recognised.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamSource {
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// Pinned by an offline playlist; the cache pruner skips it.
#[serde(default)]
pub keep: bool,
}
/// What the page sends when it remembers (or re-sends) a source.
#[derive(Debug, Clone, Deserialize)]
pub struct RememberArg {
pub track_id: Option<String>,
pub artist: Option<String>,
pub title: Option<String>,
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// `None` leaves an existing pin as it was; `Some` sets it.
#[serde(default)]
pub keep: Option<bool>,
}
/// More than the page keeps (400): this file also holds the offline pins,
/// and a pinned source must never be the one an unpinned one pushes out.
const CAP: usize = 1200;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("stream-sources.json")
}
/// The lookup keys for a track, the page's own rule (`streamSourceKeys`):
/// the catalog id when there is one, and the lowercased `artist|title`.
pub fn keys(track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Vec<String> {
let mut out = Vec::with_capacity(2);
if let Some(t) = track_id.map(str::trim).filter(|t| !t.is_empty()) {
out.push(format!("t:{t}"));
}
if let (Some(a), Some(t)) = (artist, title) {
if !a.is_empty() && !t.is_empty() {
out.push(format!("n:{a}|{t}").to_lowercase());
}
}
out
}
/// Insertion-ordered so the cap can drop the oldest memory first — a
/// BTreeMap would forget which was old.
pub fn load(data_dir: &Path) -> Vec<(String, StreamSource)> {
std::fs::read_to_string(file(data_dir))
.ok()
.and_then(|s| serde_json::from_str::<Vec<(String, StreamSource)>>(&s).ok())
.unwrap_or_default()
}
fn save(data_dir: &Path, entries: &[(String, StreamSource)]) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(entries) {
// Written beside and renamed over: a cold browse may be reading the
// file at this exact moment, and it must see the old whole file or
// the new whole file, never half of one.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Remember these sources. Every key of an entry maps to the same record;
/// an existing key is moved to the end (newest), and `keep` is carried
/// forward from the old record unless the argument sets it.
pub fn remember(data_dir: &Path, args: &[RememberArg]) {
if args.is_empty() {
return;
}
let mut entries = load(data_dir);
for a in args {
if a.username.is_empty() || a.path.is_empty() || a.size == 0 {
continue;
}
for key in keys(a.track_id.as_deref(), a.artist.as_deref(), a.title.as_deref()) {
let old_keep = entries
.iter()
.position(|(k, _)| *k == key)
.map(|i| entries.remove(i).1.keep)
.unwrap_or(false);
entries.push((
key,
StreamSource {
username: a.username.clone(),
path: a.path.clone(),
size: a.size,
duration: a.duration,
keep: a.keep.unwrap_or(old_keep),
},
));
}
}
if entries.len() > CAP {
// Drop the oldest UNPINNED memories first; a pin outlives the cap.
let over = entries.len() - CAP;
let mut dropped = 0;
entries.retain(|(_, s)| {
if dropped < over && !s.keep {
dropped += 1;
false
} else {
true
}
});
}
save(data_dir, &entries);
}
/// Pin or unpin the source remembered for a track.
pub fn set_keep(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>, keep: bool) {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return;
}
let mut entries = load(data_dir);
let mut changed = false;
for (k, s) in entries.iter_mut() {
if wanted.contains(k) && s.keep != keep {
s.keep = keep;
changed = true;
}
}
if changed {
save(data_dir, &entries);
}
}
/// The remembered source for a track, catalog id first.
pub fn lookup(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Option<StreamSource> {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return None;
}
let entries = load(data_dir);
for key in &wanted {
if let Some((_, s)) = entries.iter().find(|(k, _)| k == key) {
return Some(s.clone());
}
}
None
}
/// The cache file this source would land in, if its bytes are all there.
/// `None` for a missing file or a mid-transfer prefix — a prefix is not
/// the track, and a car that started one would stop mid-song.
pub fn cached_file(data_dir: &Path, src: &StreamSource) -> Option<PathBuf> {
let file = crate::playback::cache_file_in(&crate::playback::cache_dir_in(data_dir), &src.username, &src.path);
match std::fs::metadata(&file) {
Ok(md) if src.size > 0 && md.len() == src.size => Some(file),
_ => None,
}
}
/// Every cache file a pinned source names — the pruner's exemption list.
pub fn kept_cache_files(data_dir: &Path) -> HashSet<PathBuf> {
let dir = crate::playback::cache_dir_in(data_dir);
load(data_dir)
.into_iter()
.filter(|(_, s)| s.keep)
.map(|(_, s)| crate::playback::cache_file_in(&dir, &s.username, &s.path))
.collect()
}
/// Where this instance keeps its data — the same directory the config and
/// the stream cache live in, which on Android is also the `dataDir` the
/// media service hands to the cold browse.
fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
crate::paths::app_data(app)
}
/// The page remembering a source (one), or re-sending its whole store at
/// boot so memories made before this file existed reach the cold side.
#[tauri::command]
pub fn stream_source_remember(app: tauri::AppHandle, entries: Vec<RememberArg>) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || remember(&dir, &entries));
Ok(())
}
/// Pin or unpin a track's remembered source for the cache pruner.
#[tauri::command]
pub fn stream_source_keep(
app: tauri::AppHandle,
track_id: Option<String>,
artist: Option<String>,
title: Option<String>,
keep: bool,
) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || {
set_keep(&dir, track_id.as_deref(), artist.as_deref(), title.as_deref(), keep)
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!("ss-stream-sources-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn keys_follow_the_page() {
assert_eq!(
keys(Some("42"), Some("Hermeth"), Some("Once Again")),
vec!["t:42".to_string(), "n:hermeth|once again".to_string()]
);
assert_eq!(keys(None, Some("A"), None), Vec::<String>::new());
}
#[test]
fn remember_then_lookup_by_either_key_and_keep_survives() {
let d = tmp();
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: Some("Hermeth".into()),
title: Some("Strictly Acid".into()),
username: "peer".into(),
path: "a\\b.flac".into(),
size: 10,
duration: Some(280.0),
keep: None,
}],
);
assert_eq!(lookup(&d, Some("7"), None, None).unwrap().path, "a\\b.flac");
assert_eq!(lookup(&d, None, Some("hermeth"), Some("STRICTLY ACID")).unwrap().size, 10);
set_keep(&d, Some("7"), None, None, true);
// a re-remember without `keep` carries the pin forward
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: None,
title: None,
username: "peer2".into(),
path: "c\\d.flac".into(),
size: 11,
duration: None,
keep: None,
}],
);
let s = lookup(&d, Some("7"), None, None).unwrap();
assert_eq!(s.username, "peer2");
assert!(s.keep);
assert_eq!(kept_cache_files(&d).len(), 1);
}
#[test]
fn cached_file_wants_the_whole_track() {
let d = tmp();
let src = StreamSource { username: "u".into(), path: "x\\y.mp3".into(), size: 3, duration: None, keep: false };
assert!(cached_file(&d, &src).is_none());
let f = crate::playback::cache_file_in(&crate::playback::cache_dir_in(&d), "u", "x\\y.mp3");
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(&f, b"ab").unwrap();
assert!(cached_file(&d, &src).is_none(), "a prefix is not the track");
std::fs::write(&f, b"abc").unwrap();
assert_eq!(cached_file(&d, &src), Some(f));
}
}
+21
View File
@@ -448,6 +448,27 @@ export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
/** The page's memory of a streamed track's peer file, mirrored to the Rust
* side (`stream_sources.rs`) so a process with no page — the media service
* a car starts — can tell which cache files are whole playlist rows. One
* entry when a source is remembered, the whole store once at boot. */
export const streamSourceRemember = (entries) => invoke("stream_source_remember", { entries });
/** Pin (or unpin) a track's remembered source for the cache pruner. */
export const streamSourceKeep = (trackId, artist, title, keep) =>
invoke("stream_source_keep", { trackId: trackId != null ? String(trackId) : null, artist, title, keep });
/** Name the queue for the resume point (`resume.rs`): what a cold PLAY
* would put back on. */
export const playbackNoteQueue = (items, idx, position, from) =>
invoke("playback_note_queue", { items, idx, position, from });
/** Where playback was left, as the Rust side holds it — null when nothing. */
export const playbackResumePoint = () => invoke("playback_resume_point");
/** The playlists kept offline, and the switch. */
export const playlistOfflineList = () => invoke("playlist_offline_list");
export const playlistOfflineSet = (id, keep) => invoke("playlist_offline_set", { id, keep });
/** Tell the media session that a row the car handed over did not play. */
export const remoteFailed = (message) => invoke("plugin:media-session|remote_failed", { failure: { message } });
/** DEBUG builds only: sever the server connection like a network drop would. */
export const debugDropConnection = () => invoke("debug_drop_connection");
export const streamCacheReady = (username, path, size) =>
invoke("stream_cache_ready", { username, path, size });
+16
View File
@@ -409,6 +409,9 @@ const enabled = (() => {
})();
/** Returns a Promise for mocked commands, or undefined to pass through. */
/** the playlists the mock keeps offline — the config's list, in memory */
const mockOffline = [];
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
/* the weather, BEFORE any dispatch, so a plugin: command and a catalog
@@ -831,6 +834,19 @@ function mockCatalog(cmd, args) {
record of having posted and cleared */
if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
if (cmd === "plugin:transfer-notice|clear") return Promise.resolve();
/* the car's error report, the resume point, the stream-source mirror
and the offline switch: all fire-and-forget on the page, all answered
so the mock walk never sees a rejection where the phone sees none */
if (cmd === "plugin:media-session|remote_failed") return Promise.resolve();
if (cmd === "playback_note_queue") return Promise.resolve();
if (cmd === "stream_source_remember" || cmd === "stream_source_keep") return Promise.resolve();
if (cmd === "playlist_offline_list") return Promise.resolve(mockOffline.slice());
if (cmd === "playlist_offline_set") {
const i = mockOffline.indexOf(Number(args?.id));
if (args?.keep && i < 0) mockOffline.push(Number(args.id));
if (!args?.keep && i >= 0) mockOffline.splice(i, 1);
return Promise.resolve(mockOffline.slice());
}
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+40
View File
@@ -116,6 +116,45 @@ function rememberStreamSource(item) {
map.set(key, record);
}
saveStreamSources();
/* and the Rust side's copy (stream_sources.rs), so a process with no
page — the media service a car starts — can tell that this cache file
is this playlist row. Fire and forget: a desktop build and the mock
both refuse it, and neither is a reason to forget locally. */
mirrorStreamSources([{ item, record }]);
}
function mirrorStreamSources(pairs) {
const entries = pairs.map(({ item, record }) => ({
track_id: item.trackId != null ? String(item.trackId) : null,
artist: item.artist ?? null,
title: item.title ?? null,
username: record.username,
path: record.path,
size: record.size,
duration: record.duration ?? null,
}));
if (entries.length) api.streamSourceRemember(entries).catch(() => {});
}
/** Once, at boot: push every memory this page holds to the Rust side. The
* store predates the mirror, so a phone that streamed for weeks before it
* existed has cache files the cold browse could not otherwise name. The
* page's keys are `t:<id>` and `n:<artist>|<title>`; the identity is
* recovered from the key, which is all the Rust side keys on too. */
function syncStreamSourcesToBackend() {
const map = loadStreamSources();
const seen = new Map(); // record path → { item, record }
for (const [key, record] of map) {
const k = `${record.username}|${record.path}`;
let e = seen.get(k);
if (!e) { e = { item: { trackId: null, artist: null, title: null }, record }; seen.set(k, e); }
if (key.startsWith("t:")) e.item.trackId = key.slice(2);
else if (key.startsWith("n:")) {
const bar = key.indexOf("|");
if (bar > 2) { e.item.artist = key.slice(2, bar); e.item.title = key.slice(bar + 1); }
}
}
mirrorStreamSources([...seen.values()]);
}
/** The remembered source for this track, but ONLY if its cache file is
@@ -842,6 +881,7 @@ export {
loadStreamSources,
saveStreamSources,
rememberStreamSource,
syncStreamSourcesToBackend,
cachedStreamSourceFor,
passesSourcePrefs,
asciiFold,
+7
View File
@@ -5324,6 +5324,13 @@ export function initEvents() {
void loadBuddies();
} else if (ev.kind === "disconnected") {
app.connection = "offline";
} else if (ev.kind === "reconnecting") {
// The backend is dialling the dropped session back on its own
// (commands.rs schedule_reconnect, with the server's throttle
// respected). "connecting" rather than "offline", so the login
// modal is not pushed at a session that is about to be back.
app.connection = "connecting";
app.loginError = "";
}
});
+81 -6
View File
@@ -5,10 +5,11 @@
player carries out. Everything real lives in the plugin
(crates/tauri-plugin-media-session); this is the wiring. */
import { invoke, addPluginListener } from "@tauri-apps/api/core";
import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js";
import { player, hooks, toggle, next, prev, seekSecs, playAlbum, playEntries, entriesFromResume } from "./player.svelte.js";
import { art } from "./art.svelte.js";
import { playFrom } from "./playlists.svelte.js";
import { listPlaylists, playlistItems } from "../lib/api.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { listPlaylists, playlistItems, playbackResumePoint } from "../lib/api.js";
/* THE QUEUE WINDOW. Every update carries a bounded slice of the queue —
the current item and QUEUE_WINDOW either side. Since round forty-eight
@@ -149,6 +150,7 @@ export async function bindMedia() {
case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break;
case "stop": if (playing) toggle(); break;
case "playRemote": playRemote(p.playlist, p.item); break;
case "playMediaId": playMediaId(p.desc, p.startMs); break;
}
});
} catch {
@@ -165,10 +167,14 @@ export async function bindMedia() {
*/
async function playRemote(playlistId, itemId) {
if (!playlistId) return;
/* the car is told WHY when this does not start — until 2026-09-18 a
hand-off that failed here was silence on the head unit, and the
driver could not tell a slow resolver from a dead one */
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
const pls = await listPlaylists();
const pl = (pls || []).find((p) => Number(p.id) === Number(playlistId));
if (!pl) return;
if (!pl) { tell("That playlist is gone."); return; }
const items = (await playlistItems(pl.id)) || [];
/* item 0 means "the playlist", anything else is the row that was
tapped and the rest follows it — which is what playFrom's third
@@ -176,13 +182,82 @@ async function playRemote(playlistId, itemId) {
const at = Number(itemId) ? items.find((i) => Number(i.id) === Number(itemId)) : null;
if (!playFrom(pl, items, at)) {
player.error = "Nothing in “" + pl.name + "” can play yet.";
tell(player.error);
return;
}
} catch {
/* a playlist that has gone, or a resolver that said no — the car has
already been told this row did not start. */
/* the resolver runs after playFrom returns; its verdict lands on
player.error a few seconds later. Watch for it once, so a "no
source" or "sign in" reaches the car instead of only the phone. */
const started = Date.now();
const watch = setInterval(() => {
if (player.status === "playing") { clearInterval(watch); return; }
if (player.error) { clearInterval(watch); tell(player.error); return; }
if (Date.now() - started > 45_000) clearInterval(watch);
}, 500);
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/**
* ANY row the car tapped while the app is up — the page owns the queue, so
* the tap is the page's whatever the row is. `desc` is the row as Rust
* spelled it for us (auto_browse.rs describe_json): a library record or one
* of its tracks plays the way tapping it in the app would; a playlist row
* takes the hand-off above; "resume" puts back whatever the resume point
* holds, at the position the car asked for or the saved one.
*/
async function playMediaId(descJson, startMs) {
let d = {};
try { d = JSON.parse(descJson || "{}") || {}; } catch { d = {}; }
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
switch (d.kind) {
case "album":
case "track": {
const album = lib.albums.find((a) => Number(a.id) === Number(d.albumId));
if (!album) { tell("That record is not on this phone any more."); return; }
const tracks = (await tracksOf(album.id)) || [];
if (!tracks.length) { tell("That record has no tracks on this phone."); return; }
let at = 0;
if (d.kind === "track") {
const i = tracks.findIndex((t) => Number(t.id) === Number(d.trackId));
if (i >= 0) at = i;
}
await playAlbum(album, tracks, at);
return;
}
case "playlist":
await playRemote(d.playlistId, d.itemId);
return;
case "resume":
await resumeFromPoint(startMs);
return;
default:
tell("SoulStream could not play that.");
}
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/** "Continue" with the app up: the bar's own track if there is one, else
* the resume point the Rust side holds, rebuilt as this page's queue. */
async function resumeFromPoint(startMs) {
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
if (player.track) {
if (!(player.status === "playing" || player.status === "buffering")) toggle();
return;
}
let rp = null;
try { rp = await playbackResumePoint(); } catch { rp = null; }
if (!rp || !Array.isArray(rp.items) || !rp.items.length) { tell("Nothing to continue yet."); return; }
const entries = entriesFromResume(rp.items);
const idx = Math.min(Math.max(0, rp.idx || 0), entries.length - 1);
const at = typeof startMs === "number" && startMs >= 0 ? startMs / 1000 : (rp.position || 0);
await playEntries(entries, idx, rp.from || "", at > 1 ? at : null);
}
/* the card interpolates its progress from the last update; a top-up
every half minute keeps a long track honest */
setInterval(() => { if (player.track) syncMedia(); }, 30_000);
+109 -5
View File
@@ -376,7 +376,7 @@ function advanceGaplessly(i) {
}
/* ── the queue ───────────────────────────────────────────────────────── */
export function playEntries(entries, at = 0, from = "") {
export function playEntries(entries, at = 0, from = "", startSecs = null) {
original = null;
if (player.shuffle && entries.length > 1) {
/* a predetermined order: the chosen track first, the rest shuffled */
@@ -389,8 +389,9 @@ export function playEntries(entries, at = 0, from = "") {
player.qi = at;
player.from = from;
/* the REACTIVE entry, not the raw one: a path-less first entry gets its
stream and path from the resolver, and the bar must see them land */
return startEntry(player.queue[at]);
stream and path from the resolver, and the bar must see them land.
`startSecs` is where a resumed session picks up; null is the top. */
return startEntry(player.queue[at], startSecs);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
@@ -650,9 +651,13 @@ function packEntry(e) {
album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null,
};
}
/* set once restoreSession has READ the saved session, so a write that lands
before it (a visibility change under a launch with the screen off — seen
2026-09-18) cannot remove what it is about to restore */
let sessionBooted = false;
export function writeSession() {
try {
if (!player.track) { localStorage.removeItem(SESSION_KEY); return; }
if (!player.track) { if (sessionBooted) localStorage.removeItem(SESSION_KEY); return; }
localStorage.setItem(SESSION_KEY, JSON.stringify({
v: 1,
queue: player.queue.map(packEntry),
@@ -665,6 +670,68 @@ export function writeSession() {
original: original ? original.map(packEntry) : null,
}));
} catch { /* quota, or storage off: the session is a convenience */ }
noteResume();
}
/* ── the resume point ────────────────────────────────────────────────
The same session, in the Rust side's shape (resume.rs), for everything
that can ask "what was playing?" with no page alive to answer: Android's
own resume card, a head unit's PLAY into a cold app, Android Auto's
autoplay on connection. A bounded window around the current track — the
whole queue is not a thing to write every ten seconds — and each entry
named by what plays it cold: a file on the phone, or the peer file whose
bytes may be in the stream cache. The engine's pump keeps the position;
this names the queue. Fire and forget: the desktop and the mock refuse it
and neither is a reason to fail the session write. */
const RESUME_WINDOW = 25;
function resumeItem(e) {
const t = e?.t || {};
const a = e?.album || {};
const local = !!t.path && !t.stream;
return {
id: t.id != null ? String(t.id) : null,
title: t.title || "",
artist: t.artist || a.artist || "",
album: a.title || "",
duration: t.duration_secs ?? null,
art: a.art_path || null,
path: local ? t.path : null,
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
};
}
/** The resume shape back into queue entries. A cached stream comes back as
* a plain file, which is what it is on this phone; a streamed entry whose
* cache is gone keeps its peer identity so the resolver can find it. */
export function entriesFromResume(items) {
return (items || []).map((it) => {
const local = !!it.path;
return {
t: {
id: it.id ?? it.path ?? it.title,
title: it.title || "",
artist: it.artist || "",
path: local ? it.path : (it.stream?.path || null),
duration_secs: it.duration ?? null,
stream: !local && it.stream?.username ? { username: it.stream.username, size: it.stream.size || 0 } : null,
catalog: null,
alts: [],
},
album: { id: null, catalogId: null, title: it.album || "", artist: it.artist || "", art_path: it.art || null },
};
});
}
function noteResume() {
/* NEVER cleared from here. A page with nothing on its bar is not proof
that there is nothing to continue — it is the state of every boot
before the session is restored, and a visibility change in that
window (the screen going off under a launch) wrote the resume point
away on 2026-09-18. A stale resume point costs a wrong "Continue"; a
wiped one costs the feature. */
if (!player.track || player.qi < 0) return;
const lo = Math.max(0, player.qi - RESUME_WINDOW);
const win = player.queue.slice(lo, player.qi + RESUME_WINDOW + 1);
api.playbackNoteQueue(win.map(resumeItem), player.qi - lo, player.position || 0, player.from || "").catch(() => {});
}
/** Put back what the last launch left behind. The audio thread is the
@@ -673,9 +740,42 @@ export function writeSession() {
export async function restoreSession() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
sessionBooted = true;
let transport = null;
try { transport = await api.playerState(); } catch { transport = null; }
/* A CAR was driving the engine when this page came up (a cold start
from Android Auto, then the app opened over it): the backend hands
that queue over exactly once, and it beats whatever this page's own
storage remembers — restoring an older session over live audio puts
the wrong title on the bar and the wrong record on the ground. The
entries arrive in the resume shape (resume.rs) and are turned back
into queue entries here; a cached stream comes back as a plain file,
which is what it is on this phone. */
const hand = transport?.handover;
if (hand && Array.isArray(hand.items) && hand.items[hand.idx]) {
const entries = entriesFromResume(hand.items);
const g = ++gen;
player.queue = entries;
player.qi = hand.idx;
player.from = hand.from || "";
original = null;
const e = entries[hand.idx];
player.track = e.t;
player.album = e.album;
const live = !!transport && transport.status !== "idle";
player.status = live ? transport.status : "paused";
player.position = live ? (transport.position ?? 0) : (hand.position ?? 0);
player.duration = live && transport.duration != null ? transport.duration : (e.t.duration_secs ?? null);
player.needsResource = !live;
player.phase = "ready";
void loadArt(e.album?.art_path, true);
if (e.t.path) loadWave(g, e.t.path);
if (live) armPreload();
writeSession();
mediaSync();
return true;
}
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
const g = ++gen;
player.queue = saved.queue;
player.qi = saved.qi;
@@ -696,6 +796,10 @@ export async function restoreSession() {
loadWave(g, e.t.path);
if (live) armPreload();
mediaSync();
/* the resume point too: a restored-but-paused session is exactly what a
car connecting after a restart should be able to continue, and until
something played or changed nothing had written it */
noteResume();
return true;
}
+14 -4
View File
@@ -18,7 +18,7 @@ import {
} from "../lib/sources.js";
import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { slsk, awaitOnline } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
@@ -28,6 +28,16 @@ void refreshSourcePrefs();
const online = () => slsk.connection === "online";
const OFFLINE = "Sign in on Community to play from the network.";
/** Online now — or, while the backend is dialling a dropped session back
* (or the saved account is still signing in at boot), online within a
* moment. A car tapping a streamed row the second the phone thaws must
* not be refused "signed out" by a login that lands three seconds later;
* signed out for real, this answers at once. */
async function ensureOnline() {
if (online()) return true;
if (slsk.connection !== "connecting") return false;
return awaitOnline(20_000);
}
/* ── shapes ────────────────────────────────────────────────────────── */
/** The record a streamed entry belongs to, as the player's `album`. No
@@ -119,7 +129,7 @@ export async function streamSong(artist, title, o = {}) {
await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from);
return;
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
placeholder(title, artist, album);
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
@@ -148,7 +158,7 @@ export async function streamCatalogAlbum(album, at = 0) {
const tracks = await tracksOf(local.id).catch(() => []);
if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; }
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist);
placeholder(album.title, album.artist, meta);
const catalogTracks = album.id
@@ -265,7 +275,7 @@ async function resolveEntry(e, quiet = false) {
e.t.duration_secs = cached.duration ?? e.t.duration_secs;
return true;
}
if (!online()) return false;
if (!(await ensureOnline())) return false;
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
+26
View File
@@ -34,8 +34,26 @@ export const slsk = $state({
* proved at 1.2 s and the shipped 12 s is a judgement, not a measurement. */
roomsWaitMs: 12000,
roomsLate: false,
/** the backend's automatic redial after a drop: { attempt, inSecs },
* null while online or while nobody is dialling */
redial: null,
});
/** Resolve true as soon as the hub is online, or false after `ms`. Polled
* rather than effected: this is asked from plain async code (the resolver)
* and a quarter second of latency on a twenty-second wait is nothing. The
* point is a car tapping a streamed row the moment the phone comes back
* from a pocket — the backend is already redialling, and refusing the row
* "signed out" three seconds before the login lands is the wrong answer. */
export async function awaitOnline(ms) {
const until = Date.now() + Math.max(0, ms);
while (Date.now() < until) {
if (slsk.connection === "online") return true;
await new Promise((r) => setTimeout(r, 250));
}
return slsk.connection === "online";
}
const FEED_MAX = 300;
function trim(arr) {
if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX);
@@ -159,10 +177,18 @@ export async function bindSlsk() {
slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser;
slsk.loginError = "";
slsk.redial = null;
void loadBuddies();
refreshRooms();
} else if (ev?.kind === "disconnected") {
slsk.connection = "offline";
} else if (ev?.kind === "reconnecting") {
/* the backend is dialling the dropped session back on its own
(commands.rs schedule_reconnect): the page shows "connecting",
not "offline", and the resolver waits for it rather than
refusing the next track outright */
slsk.connection = "connecting";
slsk.redial = { attempt: ev.attempt || 1, inSecs: ev.in_secs || 0 };
}
});
on("soulseek:room-list", (rooms) => {