Compare commits
10
Commits
| 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
|
## Background playback and the lock-screen card
|
||||||
|
|
||||||
Nothing in this plugin starts the service. It is **bound**, and that is half
|
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
|
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
|
||||||
action, a custom action, or media3's own self-start intent — which it
|
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`
|
recognises by a uid extra of its own making — and returns `START_STICKY`
|
||||||
without calling `startForeground()` for anything else; a plain
|
without calling `startForeground()` for anything else; a plain
|
||||||
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
||||||
`startForegroundService` would now arm Android's ten-second timer and satisfy
|
`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
|
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
|
whole app about ten seconds after a foreground start that never called
|
||||||
`startForeground()`, and on that day the first real Save died with the
|
`startForeground()`, and on that day the first real Save died with the
|
||||||
process, four tracks in. media3 promotes ITSELF, once there is something to
|
process, four tracks in. media3 promotes ITSELF, once there is something to
|
||||||
promote for — `MediaNotificationManager.startForeground()` does the
|
promote for — `MediaNotificationManager.startForeground()` does the
|
||||||
`startForegroundService` and the `setForegroundServiceNotification` in one
|
`startForegroundService` and the `setForegroundServiceNotification` in one
|
||||||
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
||||||
with the action `androidx.media3.session.MediaSessionService`, which creates
|
with the action `androidx.media3.session.MediaSessionService`, which creates
|
||||||
the service, is not subject to the Android 8+/12+ background-start
|
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
|
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
|
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
|
||||||
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
||||||
|
|
||||||
The other half is `MediaService.onStartCommand`, because the **system** starts
|
The other half is `MediaService.onStartCommand`, because the **system** starts
|
||||||
this service in the foreground whether media3 then wants it there or not.
|
this service in the foreground whether media3 then wants it there or not.
|
||||||
media3 hands the framework a media-button `PendingIntent` built with
|
media3 hands the framework a media-button `PendingIntent` built with
|
||||||
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
||||||
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
||||||
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
||||||
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
||||||
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
||||||
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
||||||
`setStartAsForegroundService(!player.playWhenReady)`, but
|
`setStartAsForegroundService(!player.playWhenReady)`, but
|
||||||
`PlaybackPendingIntentBuilder.build()` only honours it for
|
`PlaybackPendingIntentBuilder.build()` only honours it for
|
||||||
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
|
`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
|
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
|
problem. A key press arms the ten-second timer, and media3 promotes only for a
|
||||||
session it calls *user-engaged*: `playWhenReady` and
|
session it calls *user-engaged*: `playWhenReady` and
|
||||||
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
||||||
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
||||||
cannot resolve — a null session from `onGetSession` (ours never is), a custom
|
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
|
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
|
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
|
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
|
*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
|
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
|
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
|
||||||
alive for. So
|
alive for. So
|
||||||
`onStartCommand` promotes first, before `super` — with media3's own card
|
`onStartCommand` promotes first, before `super` — with media3's own card
|
||||||
through media3's own `onUpdateNotification(session, true)` when there is
|
through media3's own `onUpdateNotification(session, true)` when there is
|
||||||
something on the bar, so its `startedInForeground` bookkeeping stays true and
|
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
|
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
|
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
|
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
|
`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
|
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 —
|
library, because media3 cannot be in the foreground with nothing on the bar —
|
||||||
an empty timeline fails `shouldShowNotification`, which removes the
|
an empty timeline fails `shouldShowNotification`, which removes the
|
||||||
notification and demotes.
|
notification and demotes.
|
||||||
|
|
||||||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||||||
hands its `android/` Gradle module to tauri-build (which writes it into
|
hands its `android/` Gradle module to tauri-build (which writes it into
|
||||||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
||||||
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
||||||
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
||||||
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
`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
|
`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
|
needs a media3 session to browse, and the rounds after this one build on the
|
||||||
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
|
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
|
||||||
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
|
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
|
||||||
list and open the app at all, and `SoulStreamPlayer` is a
|
list and open the app at all, and `SoulStreamPlayer` is a
|
||||||
`SimpleBasePlayer` — media3's
|
`SimpleBasePlayer` — media3's
|
||||||
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
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
|
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
|
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
||||||
last `NowPlayingArgs` off the bridge, queue window included) and every
|
last `NowPlayingArgs` off the bridge, queue window included) and every
|
||||||
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
||||||
`src/mobile/media.svelte.js`, which did not change.
|
`src/mobile/media.svelte.js`, which did not change.
|
||||||
|
|
||||||
The session, the player, the notification provider, the audio-focus request
|
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
|
`MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs
|
||||||
`auto service destroyed`. Those two lines are the only way to count the loop
|
`auto service destroyed`. Those two lines are the only way to count the loop
|
||||||
on a real phone — see "### The Auto prober".
|
on a real phone — see "### The Auto prober".
|
||||||
|
|
||||||
Four things in that file are load-bearing and silent when they break. The
|
Four things in that file are load-bearing and silent when they break. The
|
||||||
playlist **must never be empty while a track exists**:
|
playlist **must never be empty while a track exists**:
|
||||||
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
||||||
and the card then never appears, with nothing in any log — so `getState()`
|
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
|
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
|
frontend cannot actually send an empty window with a track on it
|
||||||
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
||||||
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
|
`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
|
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.
|
not survive the two JSON hops, would take the card down silently.
|
||||||
`COMMAND_GET_TIMELINE` and
|
`COMMAND_GET_TIMELINE` and
|
||||||
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
||||||
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
||||||
down to what its commands allow — one window without the first,
|
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
|
`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`,
|
same refusal from the other end. A paused player must report `STATE_READY`,
|
||||||
not `STATE_IDLE`, or the card
|
not `STATE_IDLE`, or the card
|
||||||
goes when the music does. And both spellings of next and previous must be
|
goes when the music does. And both spellings of next and previous must be
|
||||||
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
||||||
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
|
`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
|
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
|
||||||
alone and the button appears and does nothing.
|
alone and the button appears and does nothing.
|
||||||
|
|
||||||
Two things media3 does NOT do for a custom player, whatever its own API
|
Two things media3 does NOT do for a custom player, whatever its own API
|
||||||
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
||||||
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
||||||
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
||||||
wording is "true if **the player** should handle audio focus".
|
wording is "true if **the player** should handle audio focus".
|
||||||
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
||||||
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
||||||
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
||||||
"another app takes the output → we pause" and "headphones out → we pause",
|
"another app takes the output → we pause" and "headphones out → we pause",
|
||||||
and no walk on this phone can catch either.
|
and no walk on this phone can catch either.
|
||||||
|
|
||||||
The transport handlers do not answer media3 immediately. A play, a pause or a
|
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
|
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
|
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
|
the lock screen flips back before it flips forward. Each returns an
|
||||||
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
||||||
while one is outstanding media3 shows its own optimistic placeholder, which
|
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
|
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
|
||||||
regardless, because `invalidateState()` deliberately does nothing while
|
regardless, because `invalidateState()` deliberately does nothing while
|
||||||
anything is pending: a future that is never completed does not degrade the
|
anything is pending: a future that is never completed does not degrade the
|
||||||
card, it freezes it.
|
card, it freezes it.
|
||||||
|
|
||||||
Two behaviours changed with the migration, both defensible, both of them
|
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
|
looking like bugs if nobody wrote them down. After a pause the service stays
|
||||||
in the foreground for up to ten minutes (media3's
|
in the foreground for up to ten minutes (media3's
|
||||||
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
|
`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
|
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
|
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
|
the bar; the audio focus the old service gave back by dying is now abandoned
|
||||||
by hand in `MediaHub.clear`.
|
by hand in `MediaHub.clear`.
|
||||||
|
|
||||||
What did NOT change, though it is the first thing a reading of the API
|
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
|
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 —
|
at the very top of a queue. The card's buttons are delivered as key events —
|
||||||
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
||||||
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
||||||
`BasePlayer` falls back to seeking the current item when there is no previous
|
`BasePlayer` falls back to seeking the current item when there is no previous
|
||||||
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
||||||
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
||||||
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
||||||
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
|
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
|
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
|
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
|
with repeat off, because `hasNext` is false there and the command is not
|
||||||
advertised at all, which is correct.
|
advertised at all, which is correct.
|
||||||
|
|
||||||
The glyph in the status bar is the falcon, not a note.
|
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
|
repeat-one, under which `next()` seeks instead of stepping — with the reason
|
||||||
in the message.
|
in the message.
|
||||||
|
|
||||||
### What the walk proves about the card, and what it does not
|
### What the walk proves about the card, and what it does not
|
||||||
|
|
||||||
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
|
**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
|
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,
|
(`media-card`, `one-card`); the system holding one media session for the app,
|
||||||
PLAYING (`media-session`, `one-session`); media keys reaching the player
|
PLAYING (`media-session`, `one-session`); media keys reaching the player
|
||||||
through that session with the app in front (`media-keys`); ten seconds in the
|
through that session with the app in front (`media-keys`); ten seconds in the
|
||||||
background with the position still advancing (`background`); the paused
|
background with the position still advancing (`background`); the paused
|
||||||
restore waited out past 25 s with the pid intact and no
|
restore waited out past 25 s with the pid intact and no
|
||||||
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
||||||
queue window arriving across the bridge, field for field (`queue-window`);
|
queue window arriving across the bridge, field for field (`queue-window`);
|
||||||
and both of the intent filters Android Auto knocks on — the legacy
|
and both of the intent filters Android Auto knocks on — the legacy
|
||||||
`android.media.browse.MediaBrowserService` and media3's own
|
`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
|
**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
|
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
|
nothing to attach to; `tools/auto-prober/` is the instrument for that, and
|
||||||
"### The Auto prober" under Verifying is where it lives.
|
"### The Auto prober" under Verifying is where it lives.
|
||||||
|
|
||||||
**Kyle's hands only. Nothing here can be automated, and the walk going green
|
**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 —
|
says nothing about any of it.** Unplugging headphones pauses the music —
|
||||||
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
|
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
|
||||||
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
|
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:
|
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
|
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
|
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
|
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
|
`input keyevent` does not. These four are the first things to try after the
|
||||||
first deploy of anything that touches this plugin.
|
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
|
## What a screen reader says
|
||||||
|
|
||||||
Every control on the phone is named by what it does, in the app's own
|
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
|
## 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
|
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
|
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
|
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
|
the share sheet, which no walk reaches. Work down the smoke list in "The
|
||||||
release build" before trusting a release APK on a phone.
|
release build" before trusting a release APK on a phone.
|
||||||
|
|
||||||
- **Android Auto can see the app, and there is nothing inside.** Round
|
- **Android Auto, what is still not there.** The tree, cold playback of
|
||||||
forty-nine made `MediaService` a `MediaLibraryService`, put three actions
|
library tracks and cached streams, search and voice, resume and autoplay,
|
||||||
on one intent filter (media3's `MediaLibraryService` and
|
and the error card are in (see "Android Auto: cold playback, resume, and
|
||||||
`MediaSessionService`, plus the legacy
|
what the car is told"). Not yet: a streamed row with NO cache copy still
|
||||||
`android.media.browse.MediaBrowserService` that Auto actually binds), and
|
cannot play from a cold process — the resolver is shared JS and Soulseek
|
||||||
added `res/xml/automotive_app_desc.xml` with the
|
needs a live session — and the car says so and offers to open the app;
|
||||||
`com.google.android.gms.car.application` meta-data that declares the app to
|
"Keep offline" is the answer for a playlist that matters. **Save**, the
|
||||||
Auto. So Auto lists SoulStream, opens it, and gets a root — immediately,
|
design's second transport action, is unbuilt. The car's own plays are not
|
||||||
from `Futures.immediateFuture`, with nothing on the main thread — and the
|
written to `plays` (the headless process opens the library read-only by
|
||||||
root is empty. `onGetChildren` is not overridden, so the default answers
|
rule), so a record heard only in the car does not climb the phone's Recent
|
||||||
`ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in
|
— the resume row covers the common case. Catalog browsing beyond
|
||||||
it. What is still missing is everything under it: the library read out of
|
playlists, the queue as a browsable list, and the `queueBase` →
|
||||||
the database **read-only** from a process with no Activity and no Tauri
|
`playQueueAt()` mapping for tapping a queue row are still absent.
|
||||||
(`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.
|
|
||||||
@@ -49,6 +49,26 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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.
|
<!-- What puts SoulStream in Android Auto's app list at all.
|
||||||
The resource is res/xml/automotive_app_desc.xml and the
|
The resource is res/xml/automotive_app_desc.xml and the
|
||||||
reference is resolved at LINK time, so a missing file fails
|
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])
|
@OptIn(markerClass = [UnstableApi::class])
|
||||||
object MediaHub {
|
object MediaHub {
|
||||||
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
@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
|
@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 */
|
/** the cover as ENCODED bytes — media3 decodes them itself */
|
||||||
@Volatile var artBytes: ByteArray? = null
|
@Volatile var artBytes: ByteArray? = null
|
||||||
@Volatile var artKey: String? = null
|
@Volatile var artKey: String? = null
|
||||||
@@ -189,6 +207,24 @@ object MediaHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state = np
|
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)
|
bind(app)
|
||||||
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
|
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
|
||||||
verifyApplicationThread(), a Tauri @Command is not promised to run
|
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
|
because the update that MADE the service found `service` still
|
||||||
null; there is nothing to catch up now. */
|
null; there is nothing to catch up now. */
|
||||||
if (np.playing) requestFocus(app)
|
if (np.playing) requestFocus(app)
|
||||||
player?.let {
|
player?.settle()
|
||||||
it.settle()
|
refreshSoon()
|
||||||
it.refresh()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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
|
artKey = null
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
dropFocus(app)
|
dropFocus(app)
|
||||||
player?.let {
|
player?.settle()
|
||||||
it.settle()
|
refreshSoon()
|
||||||
it.refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +611,14 @@ object MediaHub {
|
|||||||
fun act(action: String, position: Double = -1.0) {
|
fun act(action: String, position: Double = -1.0) {
|
||||||
onAction?.invoke(action, position)
|
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_BROWSABLE_HINT, CONTENT_STYLE_LIST)
|
||||||
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
|
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
|
||||||
val rootParams = LibraryParams.Builder().setExtras(b).build()
|
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))
|
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
|
* 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
|
* 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
|
* the hint itself. Albums are sleeves — the tab that lists them, and each
|
||||||
* artist whose children are albums. */
|
* artist whose children are albums. */
|
||||||
private fun styleExtras(id: String): android.os.Bundle? {
|
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
|
if (!grid) return null
|
||||||
val b = android.os.Bundle()
|
val b = android.os.Bundle()
|
||||||
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
|
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
|
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
|
||||||
* a browse, it asks for a node and draws what comes back.
|
* 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(
|
override fun onGetChildren(
|
||||||
session: MediaLibrarySession,
|
session: MediaLibrarySession,
|
||||||
browser: MediaSession.ControllerInfo,
|
browser: MediaSession.ControllerInfo,
|
||||||
@@ -814,6 +1069,19 @@ object LibraryTree : MediaLibrarySession.Callback {
|
|||||||
*/
|
*/
|
||||||
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
|
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
|
||||||
if (path.isNullOrEmpty()) return null
|
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 {
|
return try {
|
||||||
val uri =
|
val uri =
|
||||||
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
|
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
|
||||||
@@ -917,7 +1185,21 @@ object LibraryTree : MediaLibrarySession.Callback {
|
|||||||
val tab = tabIcon(ctx, id)
|
val tab = tabIcon(ctx, id)
|
||||||
if (tab != null) md.setArtworkUri(tab)
|
if (tab != null) md.setArtworkUri(tab)
|
||||||
else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
|
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())
|
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -1096,8 +1378,27 @@ class MediaService : MediaLibraryService() {
|
|||||||
stops. Promoting after that would put a service already on its way
|
stops. Promoting after that would put a service already on its way
|
||||||
out back into the foreground. A null intent is the system
|
out back into the foreground. A null intent is the system
|
||||||
re-creating a START_STICKY service — not a foreground start, and
|
re-creating a START_STICKY service — not a foreground start, and
|
||||||
it needs nothing. */
|
it needs nothing.
|
||||||
if (intent != null) holdForeground()
|
|
||||||
|
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)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1221,9 +1522,18 @@ object AutoNative {
|
|||||||
* left in silence.
|
* left in silence.
|
||||||
*
|
*
|
||||||
* A tapped track brings its album with it, so the record keeps playing
|
* 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
|
/** pause / resume / stop against the process's engine, for when there is
|
||||||
* no WebView to take the usual round trip. */
|
* 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
|
@JvmStatic
|
||||||
fun headlessTrack(json: String) {
|
fun headlessTrack(json: String) {
|
||||||
try {
|
try {
|
||||||
@@ -1295,11 +1695,16 @@ object AutoNative {
|
|||||||
np.playing = true
|
np.playing = true
|
||||||
np.hasNext = o.optBoolean("hasNext", false)
|
np.hasNext = o.optBoolean("hasNext", false)
|
||||||
np.hasPrev = true
|
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 }
|
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
|
||||||
np.artKey = art
|
np.artKey = art
|
||||||
np.art = artB64(art)
|
np.art = artB64(art)
|
||||||
MediaHub.update(ctx, np)
|
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) {
|
} catch (e: Throwable) {
|
||||||
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
|
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -28,6 +28,16 @@ class NowPlayingArgs {
|
|||||||
var art: String? = null
|
var art: String? = null
|
||||||
var hasNext: Boolean = true
|
var hasNext: Boolean = true
|
||||||
var hasPrev: 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.
|
/** the queue around the current item — it and up to ten either side.
|
||||||
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
||||||
* the media3 playlist the card is built from, and the index into it.
|
* the media3 playlist the card is built from, and the index into it.
|
||||||
@@ -63,6 +73,13 @@ class QueueItemArgs {
|
|||||||
var artKey: String? = null
|
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.
|
* 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
|
* `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)
|
payload.put("position", position)
|
||||||
trigger("action", payload)
|
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
|
@Command
|
||||||
@@ -115,6 +158,15 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
|
|||||||
invoke.resolve()
|
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
|
/** Android 13+: the card is a notification and needs the permission; asked
|
||||||
* once, the first time something plays. Media controls in the shade and
|
* once, the first time something plays. Media controls in the shade and
|
||||||
* on the lock screen follow the session either way. */
|
* 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 ──────────────────────────────────────── */
|
/* ── the state media3 reads ──────────────────────────────────────── */
|
||||||
|
|
||||||
override fun getState(): SimpleBasePlayer.State {
|
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
|
val np = MediaHub.state
|
||||||
?: return SimpleBasePlayer.State.Builder()
|
?: return SimpleBasePlayer.State.Builder()
|
||||||
.setAvailableCommands(commands(null))
|
.setAvailableCommands(commands(null))
|
||||||
@@ -105,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
|||||||
per rebuild and is always true. */
|
per rebuild and is always true. */
|
||||||
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
||||||
var sounding = np.playing
|
var sounding = np.playing
|
||||||
|
var buffering = false
|
||||||
if (headless()) {
|
if (headless()) {
|
||||||
posMs = AutoNative.positionMs()
|
posMs = AutoNative.positionMs()
|
||||||
sounding = AutoNative.sounding()
|
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()
|
return SimpleBasePlayer.State.Builder()
|
||||||
.setAvailableCommands(commands(np))
|
.setAvailableCommands(commands(np))
|
||||||
/* STATE_READY even when paused. shouldShowNotification treats
|
/* STATE_READY even when paused. shouldShowNotification treats
|
||||||
STATE_IDLE as "nothing to show" unless it has been prepared and
|
STATE_IDLE as "nothing to show" unless it has been prepared and
|
||||||
stopped, so a paused player reporting IDLE loses its card.
|
stopped, so a paused player reporting IDLE loses its card.
|
||||||
There is no buffering state to report either: media.svelte.js
|
The page's own player folds buffering into `playing`; only the
|
||||||
already folds `buffering` into `playing`. */
|
headless fetch reports BUFFERING, above. */
|
||||||
.setPlaybackState(Player.STATE_READY)
|
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
|
||||||
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||||
.setPlaylist(items)
|
.setPlaylist(items)
|
||||||
.setCurrentMediaItemIndex(at)
|
.setCurrentMediaItemIndex(at)
|
||||||
/* The long overload, not a PositionSupplier: State's own
|
/* The long overload, not a PositionSupplier: State's own
|
||||||
@@ -165,9 +188,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
|||||||
artKey: String?,
|
artKey: String?,
|
||||||
current: Boolean,
|
current: Boolean,
|
||||||
): SimpleBasePlayer.MediaItemData {
|
): 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()
|
val md = MediaMetadata.Builder()
|
||||||
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
||||||
.setArtist(artist ?: "")
|
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
|
||||||
.setAlbumTitle(album ?: "")
|
.setAlbumTitle(album ?: "")
|
||||||
.setIsBrowsable(false)
|
.setIsBrowsable(false)
|
||||||
.setIsPlayable(true)
|
.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
|
lock screen shows a grey hole. media3 decodes these bytes
|
||||||
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
|
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
|
||||||
is one BitmapFactory call this file no longer makes. */
|
is one BitmapFactory call this file no longer makes. */
|
||||||
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
|
val bytes = MediaHub.artBytes
|
||||||
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
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()) {
|
} else if (!artKey.isNullOrEmpty()) {
|
||||||
/* A LOCAL path only. A catalog record's art key is an http URL,
|
/* A LOCAL path only. A catalog record's art key is an http URL,
|
||||||
and media3's default loader would happily fetch it — twenty
|
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 ──────────────────────── */
|
/* ── the transport, back out to the WebView ──────────────────────── */
|
||||||
|
|
||||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
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")
|
AutoNative.transport(if (playWhenReady) "resume" else "pause")
|
||||||
return Futures.immediateVoidFuture()
|
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 i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
|
||||||
val id = mediaItems.getOrNull(i)?.mediaId
|
val id = mediaItems.getOrNull(i)?.mediaId
|
||||||
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
|
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>(
|
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
|
// FOCUS FIRST, and it is not optional. Without it the track
|
||||||
// opens into a port the framework keeps muted: the first run
|
// opens into a port the framework keeps muted: the first run
|
||||||
// of this path showed a live AAudio track on our uid with
|
// 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
|
// audible to nobody. On the WebView path the JS asks for
|
||||||
// focus; a car tapping a cold process has no JS to do it.
|
// focus; a car tapping a cold process has no JS to do it.
|
||||||
MediaHub.requestFocus(ctx.applicationContext)
|
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 o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
|
||||||
val queued = o.optInt("queued", 0)
|
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) {
|
if (queued == 0) {
|
||||||
// Nothing resolved: give the focus back rather than
|
// Nothing on this phone can play it, and the process is
|
||||||
// holding another app's audio hostage for silence.
|
// 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)
|
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 {
|
} else {
|
||||||
// Write the card ourselves. `getState()` mirrors
|
// Write the card ourselves. `getState()` mirrors
|
||||||
// MediaHub.state, and the only thing that ever fills it is
|
// 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.artist = o.optString("artist").ifEmpty { null }
|
||||||
np.album = o.optString("album").ifEmpty { null }
|
np.album = o.optString("album").ifEmpty { null }
|
||||||
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
|
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.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
|
np.hasPrev = false
|
||||||
// The cover, which is also the colour: Android Auto washes
|
// The cover, which is also the colour: Android Auto washes
|
||||||
// the playback screen with whatever artwork it is handed,
|
// 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<*> {
|
override fun handleStop(): ListenableFuture<*> {
|
||||||
|
if (headless() || !MediaHub.warm()) {
|
||||||
|
AutoNative.transport("stop")
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
MediaHub.act("stop")
|
MediaHub.act("stop")
|
||||||
return awaitUpdate()
|
return awaitUpdate()
|
||||||
}
|
}
|
||||||
@@ -404,7 +501,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
|||||||
* and nothing else. */
|
* and nothing else. */
|
||||||
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
||||||
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
|
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
|
||||||
if (headless()) {
|
if (headless() || !MediaHub.warm()) {
|
||||||
when (seekCommand) {
|
when (seekCommand) {
|
||||||
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
|
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
|
||||||
AutoNative.transport("next")
|
AutoNative.transport("next")
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
// event. They are commands like any other to the ACL: without them here the
|
// event. They are commands like any other to the ACL: without them here the
|
||||||
// listener was refused ("media-session.registerListener not allowed") and
|
// listener was refused ("media-session.registerListener not allowed") and
|
||||||
// the card's buttons went nowhere.
|
// 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() {
|
fn main() {
|
||||||
// Registers the command permissions (permissions/autogenerated) and, on
|
// 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-update`
|
||||||
- `allow-clear`
|
- `allow-clear`
|
||||||
|
- `allow-remote-failed`
|
||||||
- `allow-registerListener`
|
- `allow-registerListener`
|
||||||
- `allow-removeListener`
|
- `allow-removeListener`
|
||||||
|
|
||||||
@@ -76,6 +77,32 @@ Denies the registerListener command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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`
|
`media-session:allow-removeListener`
|
||||||
|
|
||||||
</td>
|
</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
|
takes the card down, and the two listener commands are what lets the page
|
||||||
hear the card's buttons (`addPluginListener`).
|
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",
|
"const": "deny-registerListener",
|
||||||
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
"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.",
|
"description": "Enables the removeListener command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -343,10 +355,10 @@
|
|||||||
"markdownDescription": "Denies the update command without any pre-configured scope."
|
"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",
|
"type": "string",
|
||||||
"const": "default",
|
"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> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::new("media-session")
|
Builder::new("media-session")
|
||||||
.invoke_handler(tauri::generate_handler![update, clear])
|
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
|
||||||
.setup(|app, api| {
|
.setup(|app, api| {
|
||||||
#[cfg(target_os = "android")]
|
#[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
|
# 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.
|
# plugin (the media session) compiled fine and never reached the APK.
|
||||||
export TAURI_ANDROID_PROJECT_PATH="$(cygpath -w "$PWD/src-tauri/gen/android")"
|
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"
|
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
|
//! 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
|
//! is handed nothing draws an empty folder; a head unit that is left waiting
|
||||||
//! drops the app. Empty is always the better answer.
|
//! 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::objects::{JObject, JString};
|
||||||
use jni::sys::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.
|
/// with a media id that happens to be a file path.
|
||||||
pub const ROOT: &str = "ss:root";
|
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 ·
|
/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists ·
|
||||||
/// Saved). Playlists is deliberately absent: the phone holds exactly one, and
|
/// 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.
|
/// an empty tab is a dead quarter of the only four root slots Auto gives us.
|
||||||
@@ -50,11 +60,10 @@ struct Node {
|
|||||||
subtitle: String,
|
subtitle: String,
|
||||||
browsable: bool,
|
browsable: bool,
|
||||||
playable: 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.
|
/// path can become on the car's side; this layer only reports it.
|
||||||
art: Option<String>,
|
art: Option<String>,
|
||||||
/// Absolute path to the audio file, for playable rows. Round fifty-one is
|
/// Absolute path to the audio file, for playable rows.
|
||||||
/// what does anything with it.
|
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +86,7 @@ impl Node {
|
|||||||
/// called here because it takes a `tauri::AppHandle`). The database itself
|
/// called here because it takes a `tauri::AppHandle`). The database itself
|
||||||
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
|
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
|
||||||
/// stored paths portable.
|
/// 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"))
|
let configured = std::fs::read_to_string(data_dir.join("config.json"))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).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()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
fn recent(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
|
||||||
// `plays` records what was played by NAME, not by track id, so getting
|
// RECENTLY PLAYED ALBUMS, not tracks. A car's "Recent" is "put that back
|
||||||
// back to a playable row means matching the pair again. Anything played
|
// on", and what a driver means by that is the record — a list of single
|
||||||
// that is not in the library any more simply drops out of the join, which
|
// songs plays one and stops.
|
||||||
// is the right answer for a tab whose whole promise is that tapping a row
|
//
|
||||||
// works.
|
// `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(
|
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
|
FROM plays p
|
||||||
JOIN tracks t
|
JOIN tracks t
|
||||||
ON t.title = p.title COLLATE NOCASE
|
ON t.title = p.title COLLATE NOCASE
|
||||||
AND t.artist = p.artist COLLATE NOCASE
|
AND t.artist = p.artist COLLATE NOCASE
|
||||||
JOIN albums a ON a.id = t.album_id
|
JOIN albums a ON a.id = t.album_id
|
||||||
GROUP BY t.id
|
GROUP BY a.id
|
||||||
ORDER BY MAX(p.started_at) DESC
|
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",
|
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()
|
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
|
/// 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
|
/// nothing, which is what a head unit asking about a node we have since
|
||||||
/// stopped publishing should get.
|
/// 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 {
|
match parent {
|
||||||
ROOT => Ok(tabs()),
|
ROOT => Ok(tabs()),
|
||||||
"ss:albums" => albums(conn),
|
"ss:albums" => albums(conn),
|
||||||
"ss:artists" => artists(conn),
|
"ss:artists" => artists(conn),
|
||||||
"ss:saved" => saved(conn),
|
"ss:saved" => saved(conn),
|
||||||
"ss:recent" => recent(conn),
|
"ss:recent" => recent(conn, data_dir),
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(id) = parent.strip_prefix("ss:album:") {
|
if let Some(id) = parent.strip_prefix("ss:album:") {
|
||||||
match id.parse::<i64>() {
|
match id.parse::<i64>() {
|
||||||
Ok(n) => album_tracks(conn, n),
|
Ok(n) => album_tracks(conn, n),
|
||||||
Err(_) => Ok(Vec::new()),
|
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:") {
|
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
|
||||||
// The rest of the id is the artist's name verbatim, however
|
// The rest of the id is the artist's name verbatim, however
|
||||||
// many colons it happens to contain.
|
// 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
|
/// 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
|
/// 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
|
/// "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(
|
pub(crate) fn resolve_playable(
|
||||||
data_dir: &str,
|
data_dir: &str,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
|
) -> Option<(Vec<crate::headless::PlayTrack>, usize, String)> {
|
||||||
use crate::headless::PlayTrack;
|
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) {
|
let conn = match open_read_only(&db) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -353,7 +673,7 @@ pub(crate) fn resolve_playable(
|
|||||||
return None;
|
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(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
|
"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
|
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",
|
ORDER BY t.disc_no, t.track_no, t.title",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([album_id], |r| {
|
let rows = stmt.query_map([album_id], |r| {
|
||||||
|
let id = r.get::<_, i64>(0)?;
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, i64>(0)?,
|
id,
|
||||||
PlayTrack {
|
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)?,
|
title: r.get(2)?,
|
||||||
artist: r.get(3)?,
|
artist: r.get(3)?,
|
||||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||||
album: r.get(5)?,
|
album: r.get(5)?,
|
||||||
art: r.get(6)?,
|
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:") {
|
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||||
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
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:") {
|
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||||
let track_id = id.parse::<i64>().ok()?;
|
let track_id = id.parse::<i64>().ok()?;
|
||||||
@@ -387,9 +813,9 @@ pub(crate) fn resolve_playable(
|
|||||||
r.get(0)
|
r.get(0)
|
||||||
})
|
})
|
||||||
.ok()?;
|
.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);
|
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
|
None
|
||||||
}
|
}
|
||||||
@@ -478,9 +904,16 @@ fn browse_json(data_dir: &str, parent: &str) -> String {
|
|||||||
if parent == ROOT {
|
if parent == ROOT {
|
||||||
return rows(&tabs());
|
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) {
|
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}"));
|
warn(&format!("auto browse {parent}: {e}"));
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}),
|
}),
|
||||||
@@ -502,7 +935,7 @@ fn rows(nodes: &[Node]) -> String {
|
|||||||
/// A cold service process has never run Tauri, so nothing has installed an
|
/// 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
|
/// `android_logger` and `log::warn!` goes nowhere — which is why the first
|
||||||
/// failure here was silent. Write to the platform log directly.
|
/// 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();
|
let tag = std::ffi::CString::new("SoulStream").unwrap_or_default();
|
||||||
if let Ok(m) = std::ffi::CString::new(msg) {
|
if let Ok(m) = std::ffi::CString::new(msg) {
|
||||||
// SAFETY: both pointers are valid NUL-terminated strings for the call.
|
// SAFETY: both pointers are valid NUL-terminated strings for the call.
|
||||||
@@ -527,16 +960,21 @@ extern "C" {
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
data_dir: JString,
|
data_dir: JString,
|
||||||
parent: JString,
|
parent: JString,
|
||||||
) -> 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
|
// 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.
|
// 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 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 par: Option<String> = env.get_string(&parent).ok().map(Into::into);
|
||||||
let out = match (dir, par) {
|
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(),
|
_ => "[]".to_owned(),
|
||||||
};
|
};
|
||||||
// Even the hand-back has to not panic across the boundary: an empty array
|
// 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())
|
.unwrap_or(std::ptr::null_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
|
/// `AutoNative.play(dataDir, mediaId, startMs)` — start playback from a
|
||||||
/// a process that may have no Activity and no WebView.
|
/// 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
|
/// 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
|
/// 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
|
/// Soulseek rows with no cache copy resolve to nothing by construction, which
|
||||||
/// construction, which is the correct answer for them: they need the resolver,
|
/// is the correct answer for them: they need the resolver, which needs the
|
||||||
/// which needs the WebView.
|
/// WebView.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
data_dir: JString,
|
data_dir: JString,
|
||||||
media_id: JString,
|
media_id: JString,
|
||||||
|
start_ms: jni::sys::jlong,
|
||||||
) -> jstring {
|
) -> jstring {
|
||||||
|
crate::headless::remember_auto_native(&mut env, &this);
|
||||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
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 id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||||
let out = match (dir, id) {
|
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(),
|
_ => "{\"queued\":0}".to_owned(),
|
||||||
};
|
};
|
||||||
env.new_string(out)
|
env.new_string(out)
|
||||||
@@ -574,6 +1016,30 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
|||||||
.unwrap_or(std::ptr::null_mut())
|
.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,
|
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
|
||||||
/// same JSON shape as a browse.
|
/// same JSON shape as a browse.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -595,6 +1061,107 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
|
|||||||
.unwrap_or(std::ptr::null_mut())
|
.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
|
/// `AutoNative.shuffle()` — reorder what is still ahead in the headless
|
||||||
/// queue. Returns how many tracks were reordered, so the car can be told
|
/// queue. Returns how many tracks were reordered, so the car can be told
|
||||||
/// nothing happened rather than showing a button that silently does nothing.
|
/// 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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_this: JObject,
|
this: JObject,
|
||||||
action: JString,
|
action: JString,
|
||||||
) {
|
) {
|
||||||
|
crate::headless::remember_auto_native(&mut env, &this);
|
||||||
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
let Some(a) = env.get_string(&action).ok().map(String::from) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let audio = crate::headless::audio(None);
|
let audio = crate::headless::audio(None);
|
||||||
|
warn(&format!("auto transport {a}"));
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"pause" => audio.player.pause(),
|
"pause" => audio.player.pause(),
|
||||||
"resume" => audio.player.resume(),
|
"resume" => audio.player.resume(),
|
||||||
@@ -652,7 +1221,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
|
|||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
) -> jni::sys::jlong {
|
) -> 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
|
/// `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,
|
_env: JNIEnv,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
) -> jni::sys::jboolean {
|
) -> 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,
|
/// `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
|
/// 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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
|
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
) -> jni::sys::jboolean {
|
) -> 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 struct AppStatus {
|
||||||
pub version: &'static str,
|
pub version: &'static str,
|
||||||
pub connected_as: Option<String>,
|
pub connected_as: Option<String>,
|
||||||
|
/// A dropped session is being dialled back (state.rs `Reconnect`).
|
||||||
|
pub redialing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
|
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 {
|
Ok(AppStatus {
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
connected_as: state.connected_as.lock().await.clone(),
|
connected_as: state.connected_as.lock().await.clone(),
|
||||||
|
redialing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +464,32 @@ pub async fn connect_and_login(
|
|||||||
password: String,
|
password: String,
|
||||||
remember: bool,
|
remember: bool,
|
||||||
) -> Result<String, String> {
|
) -> 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.
|
// Drop any previous session first.
|
||||||
if let Some(old) = state.client.lock().await.take() {
|
if let Some(old) = state.client.lock().await.take() {
|
||||||
old.shutdown();
|
old.shutdown();
|
||||||
@@ -481,27 +515,62 @@ pub async fn connect_and_login(
|
|||||||
cfg.shared_folders = shares;
|
cfg.shared_folders = shares;
|
||||||
cfg.description = "Shared with SoulStream".into();
|
cfg.description = "Shared with SoulStream".into();
|
||||||
|
|
||||||
// The previous session's listener releases its port a moment after
|
// A car may already have signed this process in (headless_slsk.rs) to
|
||||||
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
|
// stream a row before the page existed. Take that session over rather
|
||||||
let (client, mut events) = {
|
// than logging in again — two logins from one address in quick
|
||||||
let mut attempt = 0;
|
// succession is what the server throttles — and read its events from
|
||||||
loop {
|
// here on; the fetch in flight keeps going on the same client.
|
||||||
match Client::connect(cfg.clone()).await {
|
#[cfg(target_os = "android")]
|
||||||
Ok(pair) => break pair,
|
let adopted = crate::headless_slsk::adopt().map(|(client, user)| {
|
||||||
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
|
log::info!("soulseek: adopting the car's session as {user}");
|
||||||
attempt += 1;
|
(client, crate::headless_slsk::subscribe())
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
});
|
||||||
|
#[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 {
|
if remember {
|
||||||
let mut save = stored.clone();
|
let mut save = stored.clone();
|
||||||
save.username = username.clone();
|
save.username = username.clone();
|
||||||
@@ -533,6 +602,12 @@ pub async fn connect_and_login(
|
|||||||
|
|
||||||
*state.client.lock().await = Some(client);
|
*state.client.lock().await = Some(client);
|
||||||
*state.connected_as.lock().await = Some(username.clone());
|
*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.
|
// Forward client events to the webview for the life of this session.
|
||||||
let event_app = app.clone();
|
let event_app = app.clone();
|
||||||
@@ -855,10 +930,45 @@ pub async fn connect_and_login(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Disconnected { reason } => {
|
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>() {
|
if let Some(st) = event_app.try_state::<AppState>() {
|
||||||
*st.connected_as.lock().await = None;
|
*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;
|
break;
|
||||||
}
|
}
|
||||||
Event::ExcludedSearchPhrases(p) => {
|
Event::ExcludedSearchPhrases(p) => {
|
||||||
@@ -1033,6 +1143,209 @@ fn now_ms() -> u64 {
|
|||||||
.unwrap_or(0)
|
.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 {
|
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"username": u.username,
|
"username": u.username,
|
||||||
@@ -4073,6 +4386,14 @@ pub async fn catalog_album_info(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
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() {
|
if let Some(client) = state.client.lock().await.take() {
|
||||||
client.shutdown();
|
client.shutdown();
|
||||||
}
|
}
|
||||||
@@ -5317,12 +5638,25 @@ pub async fn waveform(
|
|||||||
/// under the words "Nothing playing". This is the boot-time answer, the same
|
/// under the words "Nothing playing". This is the boot-time answer, the same
|
||||||
/// shape of recovery `app_status` already provides for the Soulseek connection.
|
/// shape of recovery `app_status` already provides for the Soulseek connection.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
|
pub fn player_state(players: State<'_, PlayerState>) -> PlayerStateReply {
|
||||||
players
|
let snapshot = players
|
||||||
.last_transport
|
.last_transport
|
||||||
.lock()
|
.lock()
|
||||||
.map(|snapshot| snapshot.clone())
|
.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.
|
/// 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 =
|
type DwmSet =
|
||||||
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
|
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
|
||||||
unsafe {
|
unsafe {
|
||||||
let dwm = LoadLibraryA(b"dwmapi.dll | |||||||