Compare commits
10
Commits
f59a2d773e
...
6216954f40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6216954f40 | ||
|
|
69bb722590 | ||
|
|
bb7f1f1b2d | ||
|
|
9f7661afab | ||
|
|
5ddb6d4d59 | ||
|
|
6f789687e4 | ||
|
|
17942fc04b | ||
|
|
8f46f9c663 | ||
|
|
54245a33b5 | ||
|
|
062e1845e0 |
No files matched your search
+387
-185
@@ -762,80 +762,80 @@ screen — off Windows they report None and 0.
|
||||
|
||||
## Background playback and the lock-screen card
|
||||
|
||||
Nothing in this plugin starts the service. It is **bound**, and that is half
|
||||
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
|
||||
action, a custom action, or media3's own self-start intent — which it
|
||||
recognises by a uid extra of its own making — and returns `START_STICKY`
|
||||
without calling `startForeground()` for anything else; a plain
|
||||
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
||||
`startForegroundService` would now arm Android's ten-second timer and satisfy
|
||||
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
|
||||
whole app about ten seconds after a foreground start that never called
|
||||
`startForeground()`, and on that day the first real Save died with the
|
||||
process, four tracks in. media3 promotes ITSELF, once there is something to
|
||||
promote for — `MediaNotificationManager.startForeground()` does the
|
||||
`startForegroundService` and the `setForegroundServiceNotification` in one
|
||||
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
||||
with the action `androidx.media3.session.MediaSessionService`, which creates
|
||||
the service, is not subject to the Android 8+/12+ background-start
|
||||
restriction, and arms no timer at all. The check is unchanged and one step
|
||||
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
|
||||
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
||||
|
||||
The other half is `MediaService.onStartCommand`, because the **system** starts
|
||||
this service in the foreground whether media3 then wants it there or not.
|
||||
media3 hands the framework a media-button `PendingIntent` built with
|
||||
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
||||
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
||||
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
||||
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
||||
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
||||
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
||||
`setStartAsForegroundService(!player.playWhenReady)`, but
|
||||
`PlaybackPendingIntentBuilder.build()` only honours it for
|
||||
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
|
||||
anyway; every other card button is a plain `getService`. The KEYS are the
|
||||
problem. A key press arms the ten-second timer, and media3 promotes only for a
|
||||
session it calls *user-engaged*: `playWhenReady` and
|
||||
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
||||
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
||||
cannot resolve — a null session from `onGetSession` (ours never is), a custom
|
||||
action it cannot name (we declare none), a stale self-start intent from a
|
||||
destroyed instance. Three presses qualify for none of it and would each take
|
||||
the app down about ten seconds later: a key into a process the system built
|
||||
*for* that key; a key on a card paused longer than ten minutes, once the tail
|
||||
has run out and the press does not start playback; and any key at all with the
|
||||
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
|
||||
alive for. So
|
||||
`onStartCommand` promotes first, before `super` — with media3's own card
|
||||
through media3's own `onUpdateNotification(session, true)` when there is
|
||||
something on the bar, so its `startedInForeground` bookkeeping stays true and
|
||||
the shade never sees a stand-in, and otherwise with a plain notification on an
|
||||
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
|
||||
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
|
||||
`startForeground` and `stopForeground` are **final**, so none of this can be
|
||||
done by watching them; the split above is what keeps it from fighting the
|
||||
library, because media3 cannot be in the foreground with nothing on the bar —
|
||||
an empty timeline fails `shouldShowNotification`, which removes the
|
||||
notification and demotes.
|
||||
|
||||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||||
hands its `android/` Gradle module to tauri-build (which writes it into
|
||||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
||||
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
||||
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
||||
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
||||
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
|
||||
needs a media3 session to browse, and the rounds after this one build on the
|
||||
Nothing in this plugin starts the service. It is **bound**, and that is half
|
||||
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
|
||||
action, a custom action, or media3's own self-start intent — which it
|
||||
recognises by a uid extra of its own making — and returns `START_STICKY`
|
||||
without calling `startForeground()` for anything else; a plain
|
||||
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
||||
`startForegroundService` would now arm Android's ten-second timer and satisfy
|
||||
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
|
||||
whole app about ten seconds after a foreground start that never called
|
||||
`startForeground()`, and on that day the first real Save died with the
|
||||
process, four tracks in. media3 promotes ITSELF, once there is something to
|
||||
promote for — `MediaNotificationManager.startForeground()` does the
|
||||
`startForegroundService` and the `setForegroundServiceNotification` in one
|
||||
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
||||
with the action `androidx.media3.session.MediaSessionService`, which creates
|
||||
the service, is not subject to the Android 8+/12+ background-start
|
||||
restriction, and arms no timer at all. The check is unchanged and one step
|
||||
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
|
||||
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
||||
|
||||
The other half is `MediaService.onStartCommand`, because the **system** starts
|
||||
this service in the foreground whether media3 then wants it there or not.
|
||||
media3 hands the framework a media-button `PendingIntent` built with
|
||||
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
||||
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
||||
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
||||
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
||||
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
||||
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
||||
`setStartAsForegroundService(!player.playWhenReady)`, but
|
||||
`PlaybackPendingIntentBuilder.build()` only honours it for
|
||||
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
|
||||
anyway; every other card button is a plain `getService`. The KEYS are the
|
||||
problem. A key press arms the ten-second timer, and media3 promotes only for a
|
||||
session it calls *user-engaged*: `playWhenReady` and
|
||||
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
||||
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
||||
cannot resolve — a null session from `onGetSession` (ours never is), a custom
|
||||
action it cannot name (we declare none), a stale self-start intent from a
|
||||
destroyed instance. Three presses qualify for none of it and would each take
|
||||
the app down about ten seconds later: a key into a process the system built
|
||||
*for* that key; a key on a card paused longer than ten minutes, once the tail
|
||||
has run out and the press does not start playback; and any key at all with the
|
||||
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
|
||||
alive for. So
|
||||
`onStartCommand` promotes first, before `super` — with media3's own card
|
||||
through media3's own `onUpdateNotification(session, true)` when there is
|
||||
something on the bar, so its `startedInForeground` bookkeeping stays true and
|
||||
the shade never sees a stand-in, and otherwise with a plain notification on an
|
||||
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
|
||||
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
|
||||
`startForeground` and `stopForeground` are **final**, so none of this can be
|
||||
done by watching them; the split above is what keeps it from fighting the
|
||||
library, because media3 cannot be in the foreground with nothing on the bar —
|
||||
an empty timeline fails `shouldShowNotification`, which removes the
|
||||
notification and demotes.
|
||||
|
||||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||||
hands its `android/` Gradle module to tauri-build (which writes it into
|
||||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
||||
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
||||
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
||||
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
||||
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
|
||||
needs a media3 session to browse, and the rounds after this one build on the
|
||||
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
|
||||
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
|
||||
list and open the app at all, and `SoulStreamPlayer` is a
|
||||
`SimpleBasePlayer` — media3's
|
||||
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
||||
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
|
||||
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
||||
last `NowPlayingArgs` off the bridge, queue window included) and every
|
||||
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
||||
`SimpleBasePlayer` — media3's
|
||||
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
||||
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
|
||||
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
||||
last `NowPlayingArgs` off the bridge, queue window included) and every
|
||||
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
||||
`src/mobile/media.svelte.js`, which did not change.
|
||||
|
||||
The session, the player, the notification provider, the audio-focus request
|
||||
@@ -873,75 +873,75 @@ dies; the walk's `one-session` check is what keeps that honest.
|
||||
|
||||
`MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs
|
||||
`auto service destroyed`. Those two lines are the only way to count the loop
|
||||
on a real phone — see "### The Auto prober".
|
||||
|
||||
Four things in that file are load-bearing and silent when they break. The
|
||||
playlist **must never be empty while a track exists**:
|
||||
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
||||
and the card then never appears, with nothing in any log — so `getState()`
|
||||
falls back to a one-item playlist rather than trusting the window. Today's
|
||||
frontend cannot actually send an empty window with a track on it
|
||||
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
||||
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
|
||||
there because a frontend out of step with this plugin, or a `queue` that did
|
||||
not survive the two JSON hops, would take the card down silently.
|
||||
`COMMAND_GET_TIMELINE` and
|
||||
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
||||
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
||||
down to what its commands allow — one window without the first,
|
||||
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
|
||||
same refusal from the other end. A paused player must report `STATE_READY`,
|
||||
not `STATE_IDLE`, or the card
|
||||
goes when the music does. And both spellings of next and previous must be
|
||||
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
||||
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
|
||||
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
|
||||
alone and the button appears and does nothing.
|
||||
|
||||
Two things media3 does NOT do for a custom player, whatever its own API
|
||||
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
||||
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
||||
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
||||
wording is "true if **the player** should handle audio focus".
|
||||
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
||||
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
||||
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
||||
"another app takes the output → we pause" and "headphones out → we pause",
|
||||
and no walk on this phone can catch either.
|
||||
|
||||
The transport handlers do not answer media3 immediately. A play, a pause or a
|
||||
seek is a round trip out to the WebView and back, and an instant answer has
|
||||
media3 call `getState()` on the spot — which still reports the OLD state, so
|
||||
the lock screen flips back before it flips forward. Each returns an
|
||||
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
||||
while one is outstanding media3 shows its own optimistic placeholder, which
|
||||
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
|
||||
regardless, because `invalidateState()` deliberately does nothing while
|
||||
anything is pending: a future that is never completed does not degrade the
|
||||
card, it freezes it.
|
||||
|
||||
Two behaviours changed with the migration, both defensible, both of them
|
||||
looking like bugs if nobody wrote them down. After a pause the service stays
|
||||
in the foreground for up to ten minutes (media3's
|
||||
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
|
||||
at once. And `clear` leaves the service alive with an idle session registered
|
||||
rather than stopping it, so a headset button still reaches us with nothing on
|
||||
the bar; the audio focus the old service gave back by dying is now abandoned
|
||||
by hand in `MediaHub.clear`.
|
||||
|
||||
What did NOT change, though it is the first thing a reading of the API
|
||||
suggests: previous and next still work everywhere the old card did, including
|
||||
at the very top of a queue. The card's buttons are delivered as key events —
|
||||
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
||||
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
||||
`BasePlayer` falls back to seeking the current item when there is no previous
|
||||
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
||||
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
||||
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
||||
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
|
||||
else. So the app's own `prev()` — five-second restart rule and all — runs on
|
||||
every press. The one button that really can go missing is next on the last row
|
||||
with repeat off, because `hasNext` is false there and the command is not
|
||||
on a real phone — see "### The Auto prober".
|
||||
|
||||
Four things in that file are load-bearing and silent when they break. The
|
||||
playlist **must never be empty while a track exists**:
|
||||
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
||||
and the card then never appears, with nothing in any log — so `getState()`
|
||||
falls back to a one-item playlist rather than trusting the window. Today's
|
||||
frontend cannot actually send an empty window with a track on it
|
||||
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
||||
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
|
||||
there because a frontend out of step with this plugin, or a `queue` that did
|
||||
not survive the two JSON hops, would take the card down silently.
|
||||
`COMMAND_GET_TIMELINE` and
|
||||
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
||||
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
||||
down to what its commands allow — one window without the first,
|
||||
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
|
||||
same refusal from the other end. A paused player must report `STATE_READY`,
|
||||
not `STATE_IDLE`, or the card
|
||||
goes when the music does. And both spellings of next and previous must be
|
||||
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
||||
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
|
||||
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
|
||||
alone and the button appears and does nothing.
|
||||
|
||||
Two things media3 does NOT do for a custom player, whatever its own API
|
||||
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
||||
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
||||
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
||||
wording is "true if **the player** should handle audio focus".
|
||||
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
||||
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
||||
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
||||
"another app takes the output → we pause" and "headphones out → we pause",
|
||||
and no walk on this phone can catch either.
|
||||
|
||||
The transport handlers do not answer media3 immediately. A play, a pause or a
|
||||
seek is a round trip out to the WebView and back, and an instant answer has
|
||||
media3 call `getState()` on the spot — which still reports the OLD state, so
|
||||
the lock screen flips back before it flips forward. Each returns an
|
||||
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
||||
while one is outstanding media3 shows its own optimistic placeholder, which
|
||||
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
|
||||
regardless, because `invalidateState()` deliberately does nothing while
|
||||
anything is pending: a future that is never completed does not degrade the
|
||||
card, it freezes it.
|
||||
|
||||
Two behaviours changed with the migration, both defensible, both of them
|
||||
looking like bugs if nobody wrote them down. After a pause the service stays
|
||||
in the foreground for up to ten minutes (media3's
|
||||
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
|
||||
at once. And `clear` leaves the service alive with an idle session registered
|
||||
rather than stopping it, so a headset button still reaches us with nothing on
|
||||
the bar; the audio focus the old service gave back by dying is now abandoned
|
||||
by hand in `MediaHub.clear`.
|
||||
|
||||
What did NOT change, though it is the first thing a reading of the API
|
||||
suggests: previous and next still work everywhere the old card did, including
|
||||
at the very top of a queue. The card's buttons are delivered as key events —
|
||||
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
||||
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
||||
`BasePlayer` falls back to seeking the current item when there is no previous
|
||||
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
||||
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
||||
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
||||
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
|
||||
else. So the app's own `prev()` — five-second restart rule and all — runs on
|
||||
every press. The one button that really can go missing is next on the last row
|
||||
with repeat off, because `hasNext` is false there and the command is not
|
||||
advertised at all, which is correct.
|
||||
|
||||
The glyph in the status bar is the falcon, not a note.
|
||||
@@ -1073,16 +1073,16 @@ FAILS rather than skips when it cannot be armed — a queue under three rows, or
|
||||
repeat-one, under which `next()` seeks instead of stepping — with the reason
|
||||
in the message.
|
||||
|
||||
### What the walk proves about the card, and what it does not
|
||||
|
||||
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
|
||||
one card, on the `playback` channel, carrying the playing track's own title
|
||||
(`media-card`, `one-card`); the system holding one media session for the app,
|
||||
PLAYING (`media-session`, `one-session`); media keys reaching the player
|
||||
through that session with the app in front (`media-keys`); ten seconds in the
|
||||
background with the position still advancing (`background`); the paused
|
||||
restore waited out past 25 s with the pid intact and no
|
||||
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
||||
### What the walk proves about the card, and what it does not
|
||||
|
||||
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
|
||||
one card, on the `playback` channel, carrying the playing track's own title
|
||||
(`media-card`, `one-card`); the system holding one media session for the app,
|
||||
PLAYING (`media-session`, `one-session`); media keys reaching the player
|
||||
through that session with the app in front (`media-keys`); ten seconds in the
|
||||
background with the position still advancing (`background`); the paused
|
||||
restore waited out past 25 s with the pid intact and no
|
||||
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
||||
queue window arriving across the bridge, field for field (`queue-window`);
|
||||
and both of the intent filters Android Auto knocks on — the legacy
|
||||
`android.media.browse.MediaBrowserService` and media3's own
|
||||
@@ -1103,19 +1103,212 @@ 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: everything, the way the page plays it.** Library tracks
|
||||
always did. A playlist's streamed rows play cold too — every one of them.
|
||||
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 and computes its cache file with the same hash `playback::cache_path`
|
||||
uses. A row whose file is whole plays from it and carries the host's
|
||||
"downloaded" mark; a row whose bytes are not here is still a row that
|
||||
plays, and the difference is only that mark. `resolve_playable` queues the
|
||||
WHOLE playlist in order (`headless::PlayTrack.path` is `None` for a row
|
||||
not here, `stream` its remembered peer file), and the engine's queue
|
||||
(`headless.rs`) works like the page's: a row is FOUND when the queue
|
||||
reaches it — the remembered peer file first, else one search through the
|
||||
cold Soulseek session (`headless_slsk.rs`: the stored account, the
|
||||
server's excluded phrases stripped, copies ranked close to
|
||||
`songCandidates`, the best three tried with a fifteen-second first-byte
|
||||
watchdog) — the bytes land in the stream cache and play as they arrive,
|
||||
the next THREE rows are fetched ahead (`PREFETCH_DEPTH`, after the page's
|
||||
four-second start delay), and when the immediate next is whole before the
|
||||
current one ends it is appended to the sink for a gapless boundary
|
||||
(`Queue.armed`, checked again at the boundary). A row nothing on the
|
||||
network has moves the queue on, the page's rule; only the LAST row's
|
||||
failure reaches the card. The card shows the title at once with a
|
||||
BUFFERING spinner (`headlessState`) and the track once bytes play. One
|
||||
Soulseek session per process: when the app opens over a car session,
|
||||
`login_inner` ADOPTS the client (`headless_slsk::adopt` + `subscribe`)
|
||||
rather than signing in again — two logins from one address in quick
|
||||
succession is what the server throttles — and the fetches in flight become
|
||||
the page's own preloads (`PlayerState.preloads`), so the row the car was
|
||||
fetching ahead lands gaplessly under the page too. A playlist's subtitle
|
||||
counts cached streams as "here". A tap while the app is UP is still handed
|
||||
to the page (`AutoNative.describe` → `playMediaId`), which owns the queue
|
||||
then.
|
||||
|
||||
**What the card says while a row is found.** Reaching a row whose bytes
|
||||
are not here used to read as the player going to the next track and
|
||||
freezing — and, after a skip, the OLD song kept sounding under the new
|
||||
title until the fetch delivered. Now `start_at` stops the engine before
|
||||
it looks (silence, not the wrong song), the card is built BUFFERING from
|
||||
its first frame (`headlessTrack` `resolving`), and the engine narrates on
|
||||
the card's artist line (`NowPlayingArgs.status`, sent through
|
||||
`headlessState`): "Signing in to Soulseek…", "Searching Soulseek…",
|
||||
"3 copies · connecting to <peer>…", "Streaming from <peer>…", "<peer> isn't
|
||||
sending · trying another copy…", "No copy found · trying the next track…".
|
||||
The engine's own Playing event clears the line and the artist returns. A
|
||||
row fetched ahead says "Streaming from <peer>…" for the beat it takes.
|
||||
|
||||
**Covers, always.** The car draws the app icon over a row with no cover,
|
||||
and thirty of GhettoTechie's forty rows had none — pasted-tracklist rows
|
||||
the catalog never matched. Two hands fill them. In the cold process,
|
||||
`auto_browse::embedded_cover` extracts the picture from the file's own tag
|
||||
(the desktop's `tagger::extract_art`), keyed by the AUDIO path under
|
||||
`<library>/.soulstream/stream-art/by-file/` with a miss remembered as an
|
||||
empty file, for browse rows, the Continue row and the card; a row fetched
|
||||
cold gets the same once 200 kB of it have landed (`art_after_bytes`,
|
||||
re-notifying the card). On the phone, `coverart.svelte.js` is the desktop's
|
||||
playlist backfill as a background WALK over every playlist: the file before
|
||||
the catalog (`cachedStreamSourceFor` + `stream_embedded_art`, persisted as
|
||||
the row's `art_path`), then the ONE strict ladder the import review uses
|
||||
(`findCatalogTrack`, persisted as `art_url` + `track_id`) — never a loose
|
||||
name lookup, a blank tile beats another record's cover. It runs 25 s after
|
||||
boot, again when a playlist changes, every six hours, one row at a time,
|
||||
and the open playlist page's rows go first; a miss is remembered for a day
|
||||
in localStorage, only when the catalog answered. The player's
|
||||
`ensureStreamArt` does the same for the track that is playing (embedded,
|
||||
then catalog), makes the URL the entry's art key, and hands what it found
|
||||
to the walk to persist. Because everything lands on the ROW, the car —
|
||||
which reads rows straight out of the database — has it on the next browse.
|
||||
And a car that starts the app COLD has no page to walk for it, so the
|
||||
cold process asks the catalog itself: `cover_hunt.rs` is `catalogMatch.js`
|
||||
in Rust (the same Deezer field queries through the metadata crate, a match
|
||||
only when the artist agrees, the base title agrees, the version qualifiers
|
||||
are compatible and a known duration does not contradict — keep the two in
|
||||
step), fed by every browse and every queue built from a playlist: a blank
|
||||
stream row is answered from `stream-art/catalog.json` when it has been
|
||||
asked before, else queued for one thread that looks rows up one at a
|
||||
time behind the metadata client's own Deezer pacing, writes the answer on
|
||||
the row (`art_url`, and the track id; only a NULL is ever filled — the
|
||||
library is WAL, the page's writer is another connection), and re-lists
|
||||
the playlist on the car (`AutoNative.headlessCovers` →
|
||||
`notifyChildrenChanged`). The track that is playing gets the same through
|
||||
`art_from_catalog` / `art_after_bytes` and `AutoNative.headlessArt`, which
|
||||
moves only the art, never the position. The cold Soulseek search is the
|
||||
page's two-rung ladder too: artist + simplified title, then the title
|
||||
alone with the artist required in the peer's path. The hunt's last rung is
|
||||
the page's last rung as well — the artist's whole shelf flattened to
|
||||
tracks (`catalog_artist_track_candidates`), for the records the search
|
||||
index buries: Deezer's search answers "Nirvana Smells Like Teen Spirit"
|
||||
with six live takes and never the studio cut, while Nevermind's tracklist
|
||||
has it. That rung opens up to thirty tracklists behind the Deezer pacing,
|
||||
so it is spent only on the track that is PLAYING (`lookup(…, deep)`),
|
||||
bounded to forty seconds (a miss cut short by the clock is asked again
|
||||
within the hour, not the day), and never on a browse row — the search
|
||||
rungs answer those in half a second, and the phone's own walk, which does
|
||||
run the shelf rung, writes what it finds on the row for the car to read.
|
||||
The cover memory carries the matcher's version (`HUNT_VERSION`): a "no"
|
||||
remembered by an older matcher is no answer once a rung is added.
|
||||
|
||||
**A trap that cost the first cold fetch.** The Soulseek client spawns a
|
||||
download's transfer task with `tokio::spawn`, which needs a runtime on the
|
||||
CALLING thread. The engine's resolver thread is not a Tauri command; the
|
||||
spawn panicked, the thread died without a word (a cold process has no
|
||||
stderr in logcat), and the card sat on BUFFERING for as long as anyone
|
||||
looked. Every client call from an engine thread now runs under
|
||||
`headless_slsk::rt().enter()`.
|
||||
|
||||
**The artist page's MusicBrainz pass.** The phone built its artist pages
|
||||
from Deezer's list alone (`catalog_artist`), so the releases Deezer's
|
||||
artist listing omits — EPs, and whole albums (My Bloody Valentine had four
|
||||
releases) — never appeared, and neither did the authoritative release
|
||||
types or the guest-appearance / co-tenant arbitration. The desktop's
|
||||
identity merge now lives in `src/lib/artistIdentity.js` (moved out of
|
||||
`state.svelte.js` verbatim; the desktop imports it back) and
|
||||
`catalog.svelte.js` runs it after the page is on screen
|
||||
(`applyIdentity`: `catalog_artist_identity`, one retry after eight seconds
|
||||
on an empty answer, then `mergeArtistIdentity` into the page the screens
|
||||
already read). One implementation for both surfaces, on purpose.
|
||||
|
||||
**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 no peer
|
||||
on the network has, 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 +1379,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 +2626,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
|
||||
|
||||
+419
-14
@@ -114,7 +114,25 @@ import org.json.JSONArray
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
object MediaHub {
|
||||
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
||||
/**
|
||||
* 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 the engine in this process plays what it can itself.
|
||||
*/
|
||||
@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
|
||||
@@ -189,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
|
||||
@@ -201,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,10 +357,8 @@ object MediaHub {
|
||||
artKey = null
|
||||
mainHandler.post {
|
||||
dropFocus(app)
|
||||
player?.let {
|
||||
it.settle()
|
||||
it.refresh()
|
||||
}
|
||||
player?.settle()
|
||||
refreshSoon()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +611,14 @@ object MediaHub {
|
||||
fun act(action: String, position: Double = -1.0) {
|
||||
onAction?.invoke(action, position)
|
||||
}
|
||||
|
||||
/** True when the app is up: the page owns the queue and plays every tap. */
|
||||
fun warm(): Boolean = onPlay != null
|
||||
|
||||
/** Hand a row to the app to play, as `AutoNative.describe` spelled it. */
|
||||
fun play(desc: String, startMs: Long) {
|
||||
onPlay?.invoke(desc, startMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -575,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
|
||||
@@ -607,7 +829,15 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
* the hint itself. Albums are sleeves — the tab that lists them, and each
|
||||
* artist whose children are albums. */
|
||||
private fun styleExtras(id: String): android.os.Bundle? {
|
||||
val grid = id == "ss:albums" || id.startsWith("ss:artist:")
|
||||
// Anything whose children are SLEEVES. Recent is albums and
|
||||
// playlists, a playlist is covers per track, and an artist's children
|
||||
// are records — all of them are known by the picture, which is the
|
||||
// whole reason the grid exists.
|
||||
val grid =
|
||||
id == "ss:albums" ||
|
||||
id == "ss:recent" ||
|
||||
id.startsWith("ss:artist:") ||
|
||||
id.startsWith("ss:playlist:")
|
||||
if (!grid) return null
|
||||
val b = android.os.Bundle()
|
||||
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
|
||||
@@ -632,6 +862,31 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
|
||||
* a browse, it asks for a node and draws what comes back.
|
||||
*/
|
||||
/**
|
||||
* A browser asking to be told when a parent's rows change. media3's
|
||||
* DEFAULT answers "not supported", which means nobody is ever
|
||||
* subscribed and `notifyChildrenChanged` reaches nobody — the cover
|
||||
* hunt (cover_hunt.rs) found covers for a whole playlist and the car
|
||||
* kept showing the blank list until it was left and re-entered
|
||||
* (2026-09-18). Accepting is all it takes: the host re-lists the parent
|
||||
* on every notice from then on.
|
||||
*/
|
||||
override fun onSubscribe(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<Void>> {
|
||||
android.util.Log.i("SoulStream", "auto subscribe $parentId by " + browser.packageName)
|
||||
return Futures.immediateFuture(LibraryResult.ofVoid())
|
||||
}
|
||||
|
||||
override fun onUnsubscribe(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
): ListenableFuture<LibraryResult<Void>> = Futures.immediateFuture(LibraryResult.ofVoid())
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
@@ -814,6 +1069,19 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
*/
|
||||
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
|
||||
if (path.isNullOrEmpty()) return null
|
||||
// A REMOTE cover is already a uri and needs no provider and no grant —
|
||||
// the host fetches it. Streamed playlist entries have no cover file at
|
||||
// all, only a URL, so routing one through FileProvider would fail the
|
||||
// root check and lose the art of every catalog row.
|
||||
if (path.startsWith("http://") || path.startsWith("https://") ||
|
||||
path.startsWith("content://")
|
||||
) {
|
||||
return try {
|
||||
android.net.Uri.parse(path)
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
return try {
|
||||
val uri =
|
||||
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
|
||||
@@ -917,7 +1185,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) {
|
||||
@@ -1096,8 +1378,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)
|
||||
}
|
||||
|
||||
@@ -1221,9 +1522,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. */
|
||||
@@ -1281,6 +1591,96 @@ object AutoNative {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback queue ran out — called FROM Rust, on the audio pump, like
|
||||
* [headlessTrack]. The card goes to "paused, at the end": media3 was
|
||||
* extrapolating a PLAYING position over silence for as long as anyone
|
||||
* looked, because nothing had told it the engine stopped.
|
||||
*/
|
||||
/**
|
||||
* A stream's arrival, called FROM Rust on the audio pump: "buffering"
|
||||
* while the first bytes are on their way (the card shows a spinner),
|
||||
* "playing" once they sound, "failed" with the reason when no copy ever
|
||||
* delivered — which becomes the error card, not silence.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun headlessState(json: String) {
|
||||
try {
|
||||
val ctx = MediaHub.appContext ?: return
|
||||
val o = org.json.JSONObject(json)
|
||||
val message = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
|
||||
when (o.optString("state")) {
|
||||
"buffering" -> MediaHub.state?.let {
|
||||
it.buffering = true
|
||||
if (message != null) it.status = message
|
||||
MediaHub.update(ctx, it)
|
||||
}
|
||||
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; it.status = null; MediaHub.update(ctx, it) }
|
||||
"failed" -> {
|
||||
MediaHub.state?.buffering = false
|
||||
MediaHub.state?.status = null
|
||||
val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
|
||||
MediaHub.fail(ctx, why ?: "SoulStream could not play that.")
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto state card failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun headlessEnded(json: String) {
|
||||
try {
|
||||
val ctx = MediaHub.appContext ?: return
|
||||
val np = MediaHub.state ?: return
|
||||
np.playing = false
|
||||
np.duration?.let { np.position = it }
|
||||
np.hasNext = false
|
||||
MediaHub.update(ctx, np)
|
||||
android.util.Log.i("SoulStream", "auto queue ended")
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto ended card failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover under the track that is showing changed — a picture pulled
|
||||
* out of the file once enough of it landed, or the catalog's answer
|
||||
* (cover_hunt.rs). Only the art moves; the position stays.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun headlessArt(json: String) {
|
||||
try {
|
||||
val ctx = MediaHub.appContext ?: return
|
||||
val np = MediaHub.state ?: return
|
||||
val o = org.json.JSONObject(json)
|
||||
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } ?: return
|
||||
np.artKey = art
|
||||
np.art = artB64(art)
|
||||
MediaHub.update(ctx, np)
|
||||
android.util.Log.i("SoulStream", "auto cover landed for " + np.title)
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto art card failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows of a browse parent have covers they did not have when the car
|
||||
* last listed it (cover_hunt.rs): tell the host to list it again.
|
||||
* Int.MAX_VALUE is media3's own "count unknown".
|
||||
*/
|
||||
@JvmStatic
|
||||
fun headlessCovers(json: String) {
|
||||
try {
|
||||
val parent = org.json.JSONObject(json).optString("parent").ifEmpty { return }
|
||||
val session = MediaHub.sessionOrNull() ?: return
|
||||
session.notifyChildrenChanged(parent, Int.MAX_VALUE, null)
|
||||
android.util.Log.i("SoulStream", "auto covers landed, re-listing " + parent)
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto re-list failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun headlessTrack(json: String) {
|
||||
try {
|
||||
@@ -1295,11 +1695,16 @@ object AutoNative {
|
||||
np.playing = true
|
||||
np.hasNext = o.optBoolean("hasNext", false)
|
||||
np.hasPrev = true
|
||||
/* a row whose bytes are not here yet: BUFFERING from the first
|
||||
frame, with a line that says so, rather than "playing at
|
||||
0:00" for a beat before the spinner */
|
||||
np.buffering = o.optBoolean("resolving", false)
|
||||
np.status = if (np.buffering) "Finding a copy…" else null
|
||||
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
|
||||
np.artKey = art
|
||||
np.art = artB64(art)
|
||||
MediaHub.update(ctx, np)
|
||||
android.util.Log.i("SoulStream", "auto advanced to " + np.title)
|
||||
android.util.Log.i("SoulStream", "auto advanced to " + np.title + (if (np.buffering) " (resolving)" else ""))
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
|
||||
}
|
||||
|
||||
+52
@@ -28,6 +28,16 @@ class NowPlayingArgs {
|
||||
var art: String? = null
|
||||
var hasNext: Boolean = true
|
||||
var hasPrev: Boolean = true
|
||||
/** the bytes are still on their way (a cold fetch from the network);
|
||||
* the car shows a spinner rather than a position. Never sent by the
|
||||
* page — its own player folds buffering into `playing`. */
|
||||
var buffering: Boolean = false
|
||||
/** what the engine is doing while `buffering` — "Searching Soulseek…",
|
||||
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
|
||||
* on the card's artist line until the bytes sound. A card that went
|
||||
* to the next title and sat at 0:00 read as the player freezing
|
||||
* (2026-09-18); this is the difference. */
|
||||
var status: String? = null
|
||||
/** the queue around the current item — it and up to ten either side.
|
||||
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
||||
* the media3 playlist the card is built from, and the index into it.
|
||||
@@ -63,6 +73,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,6 +97,32 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
payload.put("position", position)
|
||||
trigger("action", payload)
|
||||
}
|
||||
/* 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.onPlay = { desc, startMs ->
|
||||
val payload = JSObject()
|
||||
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
|
||||
@@ -115,6 +158,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. */
|
||||
|
||||
+112
-15
@@ -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))
|
||||
@@ -105,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
per rebuild and is always true. */
|
||||
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
||||
var sounding = np.playing
|
||||
var buffering = false
|
||||
if (headless()) {
|
||||
posMs = AutoNative.positionMs()
|
||||
sounding = AutoNative.sounding()
|
||||
/* a cold fetch whose first bytes are still on their way: the car
|
||||
draws a spinner, and media3 does not extrapolate a position
|
||||
that is not moving */
|
||||
buffering = np.buffering && !sounding
|
||||
}
|
||||
return SimpleBasePlayer.State.Builder()
|
||||
.setAvailableCommands(commands(np))
|
||||
/* STATE_READY even when paused. shouldShowNotification treats
|
||||
STATE_IDLE as "nothing to show" unless it has been prepared and
|
||||
stopped, so a paused player reporting IDLE loses its card.
|
||||
There is no buffering state to report either: media.svelte.js
|
||||
already folds `buffering` into `playing`. */
|
||||
.setPlaybackState(Player.STATE_READY)
|
||||
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||
The page's own player folds buffering into `playing`; only the
|
||||
headless fetch reports BUFFERING, above. */
|
||||
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
|
||||
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||
.setPlaylist(items)
|
||||
.setCurrentMediaItemIndex(at)
|
||||
/* The long overload, not a PositionSupplier: State's own
|
||||
@@ -165,9 +188,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
artKey: String?,
|
||||
current: Boolean,
|
||||
): SimpleBasePlayer.MediaItemData {
|
||||
/* while the engine is finding the current row's bytes, its status
|
||||
line rides the artist line — the one line the car shows under
|
||||
the title */
|
||||
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
|
||||
val md = MediaMetadata.Builder()
|
||||
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
||||
.setArtist(artist ?: "")
|
||||
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
|
||||
.setAlbumTitle(album ?: "")
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
@@ -179,8 +206,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
|
||||
@@ -252,7 +291,15 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
/* ── the transport, back out to the WebView ──────────────────────── */
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
if (headless()) {
|
||||
android.util.Log.i(
|
||||
"SoulStream",
|
||||
"auto playWhenReady=" + playWhenReady + " headless=" + 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()
|
||||
}
|
||||
@@ -308,8 +355,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
|
||||
@@ -317,14 +382,33 @@ 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 resolved: give the focus back rather than
|
||||
// holding another app's audio hostage for silence.
|
||||
// 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 is fetched from the network by
|
||||
// this process now (headless_slsk.rs); `error` is its
|
||||
// reason when that could not even start.
|
||||
MediaHub.dropFocus(ctx.applicationContext)
|
||||
val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null }
|
||||
if (why != null) {
|
||||
android.util.Log.i("SoulStream", "auto cold fetch refused: $why")
|
||||
MediaHub.fail(ctx.applicationContext, why)
|
||||
} else if (o.optBoolean("remote", false)) {
|
||||
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
|
||||
MediaHub.fail(
|
||||
ctx.applicationContext,
|
||||
"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 {
|
||||
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
|
||||
}
|
||||
} else {
|
||||
// Write the card ourselves. `getState()` mirrors
|
||||
// MediaHub.state, and the only thing that ever fills it is
|
||||
@@ -338,9 +422,18 @@ 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
|
||||
/* a cold fetch: the title now, a spinner until the first
|
||||
bytes play (headlessState turns it off) */
|
||||
np.buffering = o.optBoolean("resolving", false)
|
||||
/* the engine narrated inside the call that just returned
|
||||
(headlessState ran before this line): keep its line,
|
||||
or the first one, rather than the artist for a beat */
|
||||
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
|
||||
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,
|
||||
@@ -363,6 +456,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()
|
||||
}
|
||||
@@ -404,7 +501,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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
@@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
android auto: one engine per process, actually — init was still spawning its own
|
||||
|
||||
`headless.rs` was written around a guarantee its own doc comment made and the
|
||||
code did not keep: `playback::init` still called `Player::spawn_with_device`
|
||||
directly. So a car could start a cold process, play a record into it, and the
|
||||
moment the app's Activity came up the process built a SECOND player — two
|
||||
AAudio streams out of one app, playing over each other. That is precisely the
|
||||
failure the module exists to prevent, and nothing would have reported it but
|
||||
Kyle's ears.
|
||||
|
||||
On Android `init` now takes the process's engine instead. The event receiver is
|
||||
single-consumer and the headless pump already owns it, so the UI SUBSCRIBES to
|
||||
the pump rather than reading the engine directly; and the fallback queue is
|
||||
cleared on the way in, because from that point the JS decides what plays next
|
||||
and two things advancing one engine would skip a track each time. The desktop
|
||||
path is untouched — it still spawns its own, because there is nothing else in
|
||||
that process to share with.
|
||||
|
||||
Also here: a log line on the transport's routing decision
|
||||
(`auto playWhenReady=… headless=…` and `auto transport …`), added while
|
||||
chasing a pause that turned out to be working. It is kept because the question
|
||||
"did this button go to Rust or to a WebView that is not there" has now come up
|
||||
three times and is invisible without it.
|
||||
|
||||
The pause was real, and the measurement was not. "One active AAudio track after
|
||||
a pause" proves nothing — a paused sink still shows as active. The honest test
|
||||
is that the session reports PAUSED(2) and the position stops moving, which it
|
||||
does: 24873 ms, unchanged five seconds later.
|
||||
@@ -0,0 +1,40 @@
|
||||
android auto: Recent means albums and playlists, and is never empty over a full library
|
||||
|
||||
"Recent" answered with TRACKS out of the `plays` table, and on a phone whose
|
||||
history had just been wiped that was "No items" sitting over a full library —
|
||||
the first thing a driver sees, saying the app has nothing.
|
||||
|
||||
Three changes, each about what the word means in a car.
|
||||
|
||||
**Albums, not tracks.** "Recent" in a car is "put that back on", and what
|
||||
anyone means by that is the record. A list of single songs plays one and stops.
|
||||
`plays` records what was played by NAME rather than by track id, so getting
|
||||
back to an album means matching the pair again; anything played that has since
|
||||
left the library drops out of the join, which is right for a row whose promise
|
||||
is that tapping it works.
|
||||
|
||||
**Playlists, which were absent entirely.** They are in the database, they are
|
||||
local, and they play from cold exactly as albums do — they were missing only
|
||||
because the four root slots were spent, and Recent is somewhere they can live
|
||||
without taking one. 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 or something that needs the resolver. A playlist that is
|
||||
part catalog says so on the row — "Playlist · 4 of 9 here" — so the gap is
|
||||
declared before the tap rather than discovered after it, and tapping plays the
|
||||
local entries in order. Entries that need the phone are shown rather than
|
||||
hidden, so the car's list matches what is on the phone, and marked unplayable
|
||||
rather than pretending.
|
||||
|
||||
**An unplayed library is not an empty shelf.** With no history at all, Recent
|
||||
falls back to the newest albums, which is a defensible reading of the word and
|
||||
a great deal better than an empty tab on a fresh install.
|
||||
|
||||
`ss:playlist:<id>` and `ss:pitem:<id>` join the tree and `resolve_playable`, so
|
||||
a playlist or one of its tracks plays with the rest behind it.
|
||||
|
||||
Two fixes to the instrument that found this: `probe.sh` was passing
|
||||
`--es play ""` when no query was given, which `am start` rejects outright
|
||||
("Argument expected after play") — it is conditional now. And a splice left
|
||||
`fn track_node(fn track_node(` in the source, which rustc reports as an
|
||||
unclosed delimiter at the END of the file, pointing 400 lines away from the
|
||||
mistake.
|
||||
@@ -0,0 +1,42 @@
|
||||
android auto: a catalog row is handed to the app, not failed at
|
||||
|
||||
Every entry in a streamed playlist is `kind='stream'` with no file on this
|
||||
phone — only a catalog id. A service-only process cannot turn that into audio:
|
||||
the resolver is hundreds of lines of shared JS that exist while a WebView does
|
||||
and not otherwise. So the car tapped those rows and nothing happened, which is
|
||||
the worst of the three possible answers.
|
||||
|
||||
Now the process asks whether the APP can do it instead. Rust, finding nothing
|
||||
local, reports WHICH playlist and entry were meant rather than just failing —
|
||||
a catalog row is not a broken row, it is one this process cannot do alone, and
|
||||
that difference is what decides between an error and a hand-off. Kotlin carries
|
||||
it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded,
|
||||
which is only while a WebView exists: the same fact is both the channel and the
|
||||
test for "is the app warm". And the JS plays it with `playFrom(playlist, items,
|
||||
item)` — the exact call the half-sheet makes when the same row is tapped in the
|
||||
app, so the queue, the provenance and the failure message are the app's own.
|
||||
Nothing about the car is special once it arrives.
|
||||
|
||||
Cold, it says so instead of pretending: the focus is handed back rather than
|
||||
held for silence, and the log names the row. A phone in a pocket genuinely
|
||||
cannot play a Deezer id, and a tap that quietly does nothing is worse than one
|
||||
that stops.
|
||||
|
||||
Two things the same change fixed on the way past. A streamed entry has no cover
|
||||
FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their
|
||||
sleeves and a remote uri now goes to the host untouched instead of through a
|
||||
FileProvider whose root check it could never pass. And Recent, playlists and an
|
||||
artist's albums join Albums as GRIDS — all four are things known by the picture,
|
||||
which is what the grid is for.
|
||||
|
||||
`probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at
|
||||
all: the script force-stops SoulStream, which is precisely the app the hand-off
|
||||
needs alive.
|
||||
|
||||
**Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm
|
||||
answers `auto hand-off to the app` and the event reaches the JS with no console
|
||||
error. **Not proven:** that the audio then starts. Those Deezer ids have to go
|
||||
through the resolver exactly as they do when tapped in the app, and if the app
|
||||
cannot play them the car will not either — which is correct behaviour and
|
||||
indistinguishable, from here, from a bridge that silently failed. It wants
|
||||
checking with the app in front of you.
|
||||
@@ -20,4 +20,13 @@ export PATH="$SDK/platform-tools:$PATH"
|
||||
# module — ONLY when the CLI's variable names the project. Without it a new
|
||||
# plugin (the media session) compiled fine and never reached the APK.
|
||||
export TAURI_ANDROID_PROJECT_PATH="$(cygpath -w "$PWD/src-tauri/gen/android")"
|
||||
# Gradle's AF_UNIX pipes die anywhere under the user profile on the work
|
||||
# machine ("Unable to establish loopback connection" → "Invalid argument:
|
||||
# connect"), and every gradlew invocation in build.sh, dev.sh and the prober
|
||||
# went through it. Point its temp directory outside the profile when that
|
||||
# directory exists; a machine without it (home) is untouched.
|
||||
if [ -d /c/gradle-tmp ]; then
|
||||
export TMP='C:\gradle-tmp'
|
||||
export TEMP='C:\gradle-tmp'
|
||||
fi
|
||||
ADB="$SDK/platform-tools/adb.exe"
|
||||
+616
-43
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -77,7 +86,7 @@ impl Node {
|
||||
/// called here because it takes a `tauri::AppHandle`). The database itself
|
||||
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
|
||||
/// stored paths portable.
|
||||
fn db_path(data_dir: &Path) -> PathBuf {
|
||||
pub(crate) fn db_path(data_dir: &Path) -> PathBuf {
|
||||
let configured = std::fs::read_to_string(data_dir.join("config.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
@@ -264,24 +273,329 @@ fn saved(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
// `plays` records what was played by NAME, not by track id, so getting
|
||||
// back to a playable row means matching the pair again. Anything played
|
||||
// that is not in the library any more simply drops out of the join, which
|
||||
// is the right answer for a tab whose whole promise is that tapping a row
|
||||
// works.
|
||||
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.
|
||||
//
|
||||
// `plays` records what was played by NAME rather than by track id, so
|
||||
// getting back to an album means matching the pair again. Anything played
|
||||
// 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 t.id, t.title, t.artist, t.path, a.art_path, 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
|
||||
AND t.artist = p.artist COLLATE NOCASE
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
GROUP BY t.id
|
||||
GROUP BY a.id
|
||||
ORDER BY MAX(p.started_at) DESC
|
||||
LIMIT 12",
|
||||
)?;
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
let title: String = r.get(1)?;
|
||||
let artist: String = r.get(2)?;
|
||||
let art: Option<String> = r.get(3)?;
|
||||
let n: i64 = r.get(4)?;
|
||||
Ok(Node {
|
||||
id: format!("ss:album:{id}"),
|
||||
title,
|
||||
subtitle: format!("{artist} · {}", plural(n, "track", "tracks")),
|
||||
browsable: true,
|
||||
playable: false,
|
||||
art,
|
||||
path: None,
|
||||
})
|
||||
}) {
|
||||
out.extend(rows.flatten());
|
||||
}
|
||||
|
||||
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
|
||||
// be the first thing a driver sees and it would say "No items" over a full
|
||||
// library. Newest first is a defensible reading of recent either way.
|
||||
if out.is_empty() {
|
||||
out.extend(albums(conn)?.into_iter().take(12));
|
||||
}
|
||||
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()
|
||||
.or_else(|| crate::cover_hunt::cached(data_dir, &t.artist, &t.title)),
|
||||
path: t.here().map(|p| p.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The cover embedded in an audio file, as a file the car can be handed.
|
||||
///
|
||||
/// Thirty of GhettoTechie's forty rows have no catalog cover at all — the
|
||||
/// import never matched them — and the car drew the app's icon over every
|
||||
/// one (2026-09-18). Most of those files carry their real cover in the tag:
|
||||
/// the desktop's `stream_embedded_art` extracts exactly this, keyed by the
|
||||
/// bytes' hash. Here it is keyed by the AUDIO path, so "already extracted"
|
||||
/// is one `stat` and a browse of forty rows reads forty tags once, ever.
|
||||
/// Written beside the desktop's own extractions, under
|
||||
/// `<library>/.soulstream/stream-art/`, which the FileProvider roots reach.
|
||||
pub(crate) fn embedded_cover(data_dir: &Path, audio: &Path) -> Option<PathBuf> {
|
||||
embedded_cover_from(data_dir, audio, audio, true)
|
||||
}
|
||||
|
||||
/// [`embedded_cover`] for a file that is still arriving: keyed by the
|
||||
/// cache path it will be (`key`), read from the partial it is now
|
||||
/// (`read`). A miss is remembered only when `remember_miss` — the tag of
|
||||
/// a partial file may simply not all be here yet.
|
||||
pub(crate) fn embedded_cover_from(
|
||||
data_dir: &Path,
|
||||
key: &Path,
|
||||
read: &Path,
|
||||
remember_miss: bool,
|
||||
) -> Option<PathBuf> {
|
||||
let dir = db_path(data_dir).parent()?.join("stream-art").join("by-file");
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for b in key.to_string_lossy().bytes() {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
let out = dir.join(format!("{hash:016x}.img"));
|
||||
if matches!(std::fs::metadata(&out), Ok(md) if md.len() > 0) {
|
||||
return Some(out);
|
||||
}
|
||||
// A miss is remembered as an EMPTY file, so a tag with no picture is not
|
||||
// re-read on every browse. (A file that later gains a cover is a case
|
||||
// this library does not have.)
|
||||
if std::fs::metadata(&out).is_ok() {
|
||||
return None;
|
||||
}
|
||||
let bytes = match tagger::extract_art(read) {
|
||||
Ok(Some(b)) if !b.is_empty() => b,
|
||||
_ => {
|
||||
if remember_miss {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(&out, b"");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let tmp = out.with_extension("img.tmp");
|
||||
std::fs::write(&tmp, &bytes).ok()?;
|
||||
std::fs::rename(&tmp, &out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A playlist row from this process: its local file when it has one; for a
|
||||
/// streamed row, the byte-complete cache copy of its remembered peer file
|
||||
/// when that is here, else `None` with the peer file itself so the queue
|
||||
/// can fetch it again when it gets there (`headless.rs`).
|
||||
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(PathBuf::from), None);
|
||||
}
|
||||
if kind != "stream" {
|
||||
return (None, None);
|
||||
}
|
||||
let Some(src) = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title)) else {
|
||||
return (None, None);
|
||||
};
|
||||
let file = crate::stream_sources::cached_file(data_dir, &src);
|
||||
(
|
||||
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. 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,
|
||||
-- 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.
|
||||
(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| Ok(track_node(r)?))?;
|
||||
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 here < n {
|
||||
format!("Playlist · {} of {} here", here, n)
|
||||
} else {
|
||||
format!("Playlist · {}", plural(n, "track", "tracks"))
|
||||
};
|
||||
out.push(Node {
|
||||
id: format!("ss:playlist:{id}"),
|
||||
title: name,
|
||||
subtitle: sub,
|
||||
browsable: true,
|
||||
playable: false,
|
||||
art,
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// (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 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).0.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, duration_secs
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
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 path: Option<String> = r.get(4)?;
|
||||
let art: Option<String> = r.get(5)?;
|
||||
let kind: String = r.get(6)?;
|
||||
let track_id: Option<String> = r.get(7)?;
|
||||
let duration = r.get::<_, Option<i64>>(8)?.map(|s| s as f64);
|
||||
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.
|
||||
// A row whose bytes are not here yet is still a row that plays: the
|
||||
// tap finds a copy on the network from this process now
|
||||
// (headless_slsk.rs). It is not marked "downloaded"; that is the
|
||||
// whole difference, and it thins out as rows are heard or kept.
|
||||
let unresolved = kind == "unresolved";
|
||||
let sub = if unresolved {
|
||||
format!("{artist} · unresolved")
|
||||
} else {
|
||||
match &album {
|
||||
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
|
||||
_ => artist.clone(),
|
||||
}
|
||||
};
|
||||
// No catalog cover: the one in the file's own tag, if it has one;
|
||||
// then what the cover hunt has found; and a row still blank is
|
||||
// handed to the hunt, which re-lists this playlist on the car
|
||||
// when it has an answer (cover_hunt.rs).
|
||||
let art = art
|
||||
.or_else(|| {
|
||||
file.as_ref()
|
||||
.and_then(|p| embedded_cover(data_dir, p))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
.or_else(|| {
|
||||
if kind != "stream" {
|
||||
return None;
|
||||
}
|
||||
let found = crate::cover_hunt::cached(data_dir, &artist, &title);
|
||||
if found.is_none() {
|
||||
crate::cover_hunt::request(crate::cover_hunt::Job {
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
item_id: id,
|
||||
artist: artist.clone(),
|
||||
title: title.clone(),
|
||||
duration,
|
||||
parent: format!("ss:playlist:{playlist_id}"),
|
||||
});
|
||||
}
|
||||
found
|
||||
});
|
||||
Ok(Node {
|
||||
id: format!("ss:pitem:{id}"),
|
||||
title,
|
||||
subtitle: sub,
|
||||
browsable: false,
|
||||
playable: !unresolved,
|
||||
art,
|
||||
path: file.map(|p| p.to_string_lossy().into_owned()),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
@@ -305,19 +619,24 @@ 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>() {
|
||||
Ok(n) => album_tracks(conn, n),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
} else if let Some(id) = parent.strip_prefix("ss:playlist:") {
|
||||
match id.parse::<i64>() {
|
||||
Ok(n) => playlist_items(conn, data_dir, n),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
|
||||
// The rest of the id is the artist's name verbatim, however
|
||||
// many colons it happens to contain.
|
||||
@@ -330,7 +649,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
|
||||
@@ -343,9 +662,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) => {
|
||||
@@ -353,7 +673,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
|
||||
@@ -361,24 +681,130 @@ 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 {
|
||||
path: PathBuf::from(r.get::<_, String>(1)?),
|
||||
id: Some(id.to_string()),
|
||||
path: Some(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 EVERY row, in order, exactly as the page would: the
|
||||
// local files and whole cache copies from their files, and the rows
|
||||
// whose bytes are not here found on the network when the queue reaches
|
||||
// them — the remembered peer file first, else a search — with the next
|
||||
// few fetched ahead (`headless.rs`). Only a row the page itself could
|
||||
// not play (`unresolved`, no title) is left out.
|
||||
let playlist_rows = |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, COALESCE(art_path, art_url), kind, track_id
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
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))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows.flatten() {
|
||||
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
|
||||
if title.trim().is_empty() || !(kind == "local" || kind == "stream") {
|
||||
continue;
|
||||
}
|
||||
let (file, stream) =
|
||||
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
|
||||
if kind == "local" && file.is_none() {
|
||||
continue;
|
||||
}
|
||||
// No catalog cover: the file's own, so the playback screen and
|
||||
// the colour the car washes it with are the record's, not the
|
||||
// app icon's; else what the cover hunt has found, and a row
|
||||
// still blank is handed to it.
|
||||
let art = art
|
||||
.or_else(|| {
|
||||
file.as_ref()
|
||||
.and_then(|f| embedded_cover(dir, f))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
.or_else(|| {
|
||||
if kind != "stream" {
|
||||
return None;
|
||||
}
|
||||
let found = crate::cover_hunt::cached(dir, &artist, &title);
|
||||
if found.is_none() {
|
||||
crate::cover_hunt::request(crate::cover_hunt::Job {
|
||||
data_dir: dir.to_path_buf(),
|
||||
item_id: id,
|
||||
artist: artist.clone(),
|
||||
title: title.clone(),
|
||||
duration,
|
||||
parent: format!("ss:playlist:{playlist_id}"),
|
||||
});
|
||||
}
|
||||
found
|
||||
});
|
||||
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, from) = playlist_rows(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()?;
|
||||
let playlist_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
|
||||
[item_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
let (rows, from) = playlist_rows(playlist_id).ok()?;
|
||||
// The tapped row is where the queue starts — the one under the
|
||||
// driver's finger, never a neighbour of it.
|
||||
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()?;
|
||||
@@ -387,9 +813,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
|
||||
}
|
||||
@@ -478,9 +904,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()
|
||||
}),
|
||||
@@ -502,7 +935,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.
|
||||
@@ -527,16 +960,21 @@ extern "C" {
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
this: JObject,
|
||||
data_dir: JString,
|
||||
parent: JString,
|
||||
) -> jstring {
|
||||
// The class, for the pump's calls back into Kotlin — see headless.rs.
|
||||
crate::headless::remember_auto_native(&mut env, &this);
|
||||
// Read both strings before anything else borrows `env`; a closure over it
|
||||
// would have to be `mut` and then collide with the hand-back below.
|
||||
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
|
||||
@@ -547,25 +985,29 @@ 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,
|
||||
this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
start_ms: jni::sys::jlong,
|
||||
) -> jstring {
|
||||
crate::headless::remember_auto_native(&mut env, &this);
|
||||
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)
|
||||
@@ -574,6 +1016,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]
|
||||
@@ -595,6 +1061,107 @@ 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'` 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:") {
|
||||
return Some((id.parse::<i64>().ok()?, 0));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||
let item = id.parse::<i64>().ok()?;
|
||||
let pl: i64 = conn
|
||||
.query_row(
|
||||
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
|
||||
[item],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
return Some((pl, item));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `AutoNative.shuffle()` — reorder what is still ahead in the headless
|
||||
/// queue. Returns how many tracks were reordered, so the car can be told
|
||||
/// nothing happened rather than showing a button that silently does nothing.
|
||||
@@ -612,13 +1179,15 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle(
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
this: JObject,
|
||||
action: JString,
|
||||
) {
|
||||
crate::headless::remember_auto_native(&mut env, &this);
|
||||
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
||||
return;
|
||||
};
|
||||
let audio = crate::headless::audio(None);
|
||||
warn(&format!("auto transport {a}"));
|
||||
match a.as_str() {
|
||||
"pause" => audio.player.pause(),
|
||||
"resume" => audio.player.resume(),
|
||||
@@ -652,7 +1221,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
|
||||
@@ -662,16 +1234,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()))
|
||||
}
|
||||
+355
-21
@@ -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();
|
||||
@@ -481,27 +515,62 @@ pub async fn connect_and_login(
|
||||
cfg.shared_folders = shares;
|
||||
cfg.description = "Shared with SoulStream".into();
|
||||
|
||||
// The previous session's listener releases its port a moment after
|
||||
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
|
||||
let (client, mut events) = {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match Client::connect(cfg.clone()).await {
|
||||
Ok(pair) => break pair,
|
||||
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
|
||||
attempt += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
// A car may already have signed this process in (headless_slsk.rs) to
|
||||
// stream a row before the page existed. Take that session over rather
|
||||
// than logging in again — two logins from one address in quick
|
||||
// succession is what the server throttles — and read its events from
|
||||
// here on; the fetch in flight keeps going on the same client.
|
||||
#[cfg(target_os = "android")]
|
||||
let adopted = crate::headless_slsk::adopt().map(|(client, user)| {
|
||||
log::info!("soulseek: adopting the car's session as {user}");
|
||||
(client, crate::headless_slsk::subscribe())
|
||||
});
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let adopted: Option<(Client, tokio::sync::mpsc::UnboundedReceiver<Event>)> = None;
|
||||
|
||||
let (client, mut events, greeting) = match adopted {
|
||||
Some((client, events)) => {
|
||||
// The car's queue may be fetching the rows ahead of it. Those
|
||||
// downloads are the page's preloads from here on: their
|
||||
// completion reaches the page as `soulseek:preload`, the same
|
||||
// way its own next-track fetches do, and the page's queue —
|
||||
// which took the car's over (`restoreSession`'s handover) —
|
||||
// appends them for the gapless boundary as they land.
|
||||
#[cfg(target_os = "android")]
|
||||
if let (Some(audio), Some(ps)) =
|
||||
(crate::headless::audio_if_up(), app.try_state::<PlayerState>())
|
||||
{
|
||||
let mut preloads = ps.preloads.lock().unwrap();
|
||||
for (id, cache) in audio.fetches_in_flight() {
|
||||
preloads.entry(id).or_insert(cache);
|
||||
}
|
||||
Err(e) => return Err(format!("could not reach server: {e}")),
|
||||
}
|
||||
(client, events, "adopted".to_string())
|
||||
}
|
||||
None => {
|
||||
// The previous session's listener releases its port a moment after
|
||||
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
|
||||
let (client, events) = {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match Client::connect(cfg.clone()).await {
|
||||
Ok(pair) => break pair,
|
||||
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
|
||||
attempt += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
Err(e) => return Err(format!("could not reach server: {e}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
let greeting = client
|
||||
.login(&username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
(client, events, greeting)
|
||||
}
|
||||
};
|
||||
|
||||
let greeting = client
|
||||
.login(&username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if remember {
|
||||
let mut save = stored.clone();
|
||||
save.username = username.clone();
|
||||
@@ -533,6 +602,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 +930,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 +1143,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 +4386,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 +5638,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 +5836,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 | ||||