10 Commits
Author SHA1 Message Date
KeelrLeek 6216954f40 android: the floating back and more controls are bare glyphs with a shadow
Kyle's pick over the dark discs, seen on the artist page's hero: the
picture stays whole under them, and a two-layer drop shadow is what keeps
the chevron and the dots readable on a bright one. The tap target keeps its
size; a press shows a faint disc for feedback and nothing else.
2026-09-18 17:40:00 -05:00
KeelrLeek 69bb722590 android: the artist page, redesigned — the editorial shape
Kyle called the artist page "super glitchy" and asked for a rework in the
style of other music apps on mobile; from three mockups he picked the
editorial one (research/mobile-mockup, look B). What made it glitchy: six
sections each popped in on their own as their data landed, so the page
reflowed six times; the hero dissolved again every time a better picture
arrived; a cloud of stat chips wrapped to three lines; and the discography
tab jumped when the identity merge re-filed releases.

The page now: a 300dp portrait with the name on it and the play disc beside
it, Shuffle · Follow · one line of facts, the latest release as a card, Top
Songs as swipeable columns of four with dots ("See All" opens the list),
Albums and Singles & EPs as carousels with a link to the full discography,
On this phone, Similar Artists, and About with the bio, tags and socials.
The first sharp picture is the picture, locked; a cold visit renders
skeletons the size of the sections that are coming and swaps once; there
are no tabs on the page, the rails simply grow. No band over the hero — the
shell's two floating discs are the only chrome, as everywhere else. The
hero and its copy behind the scroller are both 300px now, as they must be.
2026-09-18 17:29:17 -05:00
KeelrLeek bb7f1f1b2d android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing
Reaching a row whose bytes are not on the phone read as the player going
to the next track and freezing — the title changed, the position sat at
0:00, nothing said why — and after a skip the old song kept sounding under
the new title until the fetch delivered, because start_at notified the
card and spawned the resolver without stopping the engine (only the
natural boundary was silent).

The engine stops before it looks now: silence, the title, a spinner. The
card is built buffering from its first frame (headlessTrack carries
`resolving`), and the engine narrates on the artist line — "Signing in to
Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…",
"Streaming from <peer>…", "<peer> isn't sending · trying another copy…",
"No copy found · trying the next track…" — through headlessState, kept in
NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the
engine's own Playing event clears it. The first tap's reply builds a fresh
state after the JNI call the engine narrated inside, so it carries the
line over rather than showing the artist for a beat.

Verified on the head unit with a three-row test playlist: a cold tap reads
"Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip
into a row not fetched yet reads "Searching Soulseek…", then "Streaming
from nouvellevag0…", then the artist again at 0:07, with the prefetch
fetching the row after it.
2026-09-18 15:44:25 -05:00
KeelrLeek 9f7661afab android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the
resolver and the Soulseek session live in the page, and a car that starts
the app cold has no page — and Kyle's answer to the label was the right
one: a tap should find a copy and play it, in the background, and a
playlist should keep going through rows that are not here exactly as the
page keeps going.

headless_slsk.rs is a Soulseek session for a process with no page: the
stored account, one search (artist + simplified title, the server's
excluded phrases stripped), copies ranked by rules close to the page's
songCandidates — the title in the filename, streamable formats, a duration
within eight per cent, lossless over 320 over the rest, the artist in the
path, a free upload slot before anything else — and a download into the
same stream cache the page uses. Its own tokio runtime: a car-started
process has no Tauri one.

headless.rs is the page's queue in Rust. resolve_playable queues EVERY row
of a playlist in order, a row not here with its remembered peer file and
no path. The engine starts a row from its file when it is whole, from the
fetch already filling its cache file when one is, and otherwise finds it
on the network right then (the remembered peer first, then the search),
playing the bytes as they land; a peer that sends nothing for fifteen
seconds is dropped for the next copy, twice, and when a row has no copy
anywhere the queue moves on — only the last row's failure reaches the
card. The next three rows are fetched ahead of time, four seconds after a
start so nothing competes with the track that is starting, and when the
immediate next is whole before the current one ends it is appended to the
sink and the boundary is gapless — the page's preloadLocal and
preloadCachedStream in one. Fetches are keyed by cache file, not index, so
a shuffle cannot start the same file from the same peer twice. The card
shows the title at once with a spinner (STATE_BUFFERING through
headlessState), then the track; every source is remembered in the mirror,
so the row is "here" next time.

One session per process: when the app opens over it, login_inner ADOPTS
the client and reads its events through subscribe() instead of logging 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, so
the row the car was fetching ahead lands gaplessly under the page too.

Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have
no catalog cover, and the car drew the app icon over them. In the cold
process the cover embedded in the file is extracted — the desktop's own
tagger::extract_art, keyed by the audio path under
<library>/.soulstream/stream-art/by-file/, a miss remembered as an empty
file — for browse rows, the card and the Continue row, and a row fetched
cold gets the same once 200 kB of it have landed. On the phone,
coverart.svelte.js is the desktop's playlist backfill as a background walk
over every playlist: the file before the catalog (the row's own cached
copy, persisted as art_path), then the ONE strict ladder the import review
uses (findCatalogTrack, persisted as art_url + track_id) — never a loose
name lookup. After boot, on every playlist change, every six hours, the
open page's rows first, a miss remembered for a day and only when the
catalog answered. The player does the same for the track that is playing
(ensureStreamArt: embedded, then catalog), makes the URL the entry's art
key and hands it to the walk to persist. The page's own hole is closed
too: a streamed row with no cover got no art key at all, and where it had
one the extractor's FILE PATH was stored where a data URL belongs; such
rows get a "stream:" key and the file is read as a thumbnail, and a stream
row's art_path now draws on the phone.

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; the
two are meant to stay in step — fed by every browse and every queue built
from a playlist. A blank stream row is answered from what was asked
before (stream-art/catalog.json, a miss for a day and only when the
catalog answered), else queued for one thread that looks rows up one at
a time behind the client's own Deezer pacing, writes the answer on the
row (art_url and the track id, only ever filling a NULL; the library is
WAL and the page's writer is another connection), and re-lists the
playlist on the car. The track that is playing gets the same, through an
entry that moves only the card's art and never its position. The cold
Soulseek search is the page's two-rung ladder as well: artist plus the
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 too, the
artist's whole shelf flattened to tracks, for the records the search
index buries — spent only on the track that is playing, bounded to forty
seconds, and the cover memory carries the matcher's version so a "no"
from an older matcher is no answer once a rung is added.

Two things the head unit found in the seam. media3's default onSubscribe
answers "not supported", so nobody was ever subscribed and the re-list
notice reached nobody; LibraryTree accepts subscriptions now, and covers
land on the car's list within seconds of being found. And 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, and
the card sat on BUFFERING; every client call from an engine thread runs
under the cold runtime's enter() now.

The phone's artist page never ran the MusicBrainz pass. It built its
discography from Deezer's list alone, so the releases Deezer's artist
listing omits — EPs, and whole albums — never appeared, nor the
authoritative release types or the guest-appearance and co-tenant
arbitration. The desktop's identity merge is a plain module now
(lib/artistIdentity.js, moved out of state.svelte.js verbatim, the
desktop importing it back), and the phone runs it after the page is on
screen — one implementation for both surfaces.

What the restarted head unit found, all in the seam between a car session
and the page — and one bug older than this branch.

The pump's calls into Kotlin never worked. "auto advanced to" has not
appeared in a single log while cold queues advanced through whole albums
under a card stuck on track one, and the new end-of-queue notice did not
fire either. FindClass on the self-attached pump thread resolves through
the system class loader, which cannot see com.soulstream.mediasession
.AutoNative; the lookup failed and the exception was cleared, silently,
every time. The class is taken from the object Kotlin hands every JNI
entry — on a Java thread, through the app's own loader — and kept as a
global reference; every pump call goes through call_auto_native.

Autoplay on connection worked unprompted and resumed Starboy at 3:50 of
3:50: the page writes the position one last time when a track runs out, so
a resume point can sit at the end. resume_queue treats a position within
two seconds of the duration as "the next item from the top", keeps every
entry (one not here is found on reach, like any other), and the headless
engine reports end-of-queue to Kotlin, which turns the card to paused. A
seek on the page reached the resume point only at the pump's next
five-second flush; seekSecs writes the session at once.

Opening the app over a cold car session handed the page the right queue
and the page called it a paused session that needed re-sourcing: the
transport snapshot it reads is built only from events after the page
subscribes, and an engine already playing sends no Playing. The bar said
paused, the card said PAUSED, the audio kept going. playback::init seeds
the snapshot from the engine's own progress when it adopts a running
engine, so the page follows what is sounding.

preview::init opened two more output streams at every launch for the
desktop's hover previews, which the phone never calls — three live AAudio
tracks on our uid with one sounding. It is gated off on Android.
2026-09-18 15:00:11 -05:00
KeelrLeek 5ddb6d4d59 tooling: gradle's temp dir on the work machine, and the walk's transient toast
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 in build.sh, dev.sh and the prober went
through it. env.sh now points TMP/TEMP at C:\gradle-tmp when that directory
exists; a machine without it is untouched.

The mock walk's artist-hero-cleared step asserted a toast — a transient the
probe landed after, twice, with the toast plainly in the still. The
expectation also accepts the hero itself being gone.

Also: commands.rs carried a real NUL byte inside b"dwmapi.dll\x00", which
made grep call the file binary; it is the \0 escape now.
2026-09-18 12:35:55 -05:00
KeelrLeek 6f789687e4 android: keep a playlist offline
"Keep offline" on a playlist's More sheet: every streamed row fetched into
the stream cache in the background and pinned there, so the whole list
plays with the app shut — in a car, in a pocket — the way a downloaded
playlist does in any other player. Not the library: "Download all tracks"
files rows as albums, which is Save; this keeps the playlist a playlist of
streamed rows that are simply already here.

One pass at a time, one row at a time: the remembered peer file first, else
the resolver's ladder over the network, then the same preload_stream the
player uses for the next track, polled until whole, then pinned in the
stream-source mirror — and the cache pruner leaves a pinned file alone,
or the retention window would quietly undo the switch. The row being played
right now is skipped (a second transfer of a file a peer is already
streaming to us deadlocks both). The fetch rides the downloads notice in
the shade, which 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 list lives in
config.json (`offline_playlists`); the header says "Kept offline · N of M
here" once a pass has looked.

Verified on the phone: the shade read "Keeping 29 tracks offline", eleven
rows were done in the first forty seconds, and the car's playlist row went
from "8 of 40 here" to "10 of 40 here" two minutes later.
2026-09-18 12:35:55 -05:00
KeelrLeek 17942fc04b android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.

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

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

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

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

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

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

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

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
2026-09-18 12:35:54 -05:00
KeelrLeek 8f46f9c663 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.
2026-09-15 17:01:43 -05:00
KeelrLeek 54245a33b5 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.
2026-09-15 15:08:21 -05:00
KeelrLeek 062e1845e0 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.
2026-09-15 14:47:40 -05:00
47 changed files with 6630 additions and 877 deletions

No files matched your search

+387 -185
View File
@@ -762,80 +762,80 @@ screen — off Windows they report None and 0.
## Background playback and the lock-screen card
Nothing in this plugin starts the service. It is **bound**, and that is half
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
action, a custom action, or media3's own self-start intent — which it
recognises by a uid extra of its own making — and returns `START_STICKY`
without calling `startForeground()` for anything else; a plain
`Intent(ctx, MediaService::class.java)` is anything else. So the old
`startForegroundService` would now arm Android's ten-second timer and satisfy
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
whole app about ten seconds after a foreground start that never called
`startForeground()`, and on that day the first real Save died with the
process, four tracks in. media3 promotes ITSELF, once there is something to
promote for — `MediaNotificationManager.startForeground()` does the
`startForegroundService` and the `setForegroundServiceNotification` in one
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
with the action `androidx.media3.session.MediaSessionService`, which creates
the service, is not subject to the Android 8+/12+ background-start
restriction, and arms no timer at all. The check is unchanged and one step
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
The other half is `MediaService.onStartCommand`, because the **system** starts
this service in the foreground whether media3 then wants it there or not.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService` — unconditionally, for every key.
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
falls to the `androidx.media3.session.MediaSessionService` filter in the
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
`setStartAsForegroundService(!player.playWhenReady)`, but
`PlaybackPendingIntentBuilder.build()` only honours it for
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
anyway; every other card button is a plain `getService`. The KEYS are the
problem. A key press arms the ten-second timer, and media3 promotes only for a
session it calls *user-engaged*: `playWhenReady` and
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
cannot resolve — a null session from `onGetSession` (ours never is), a custom
action it cannot name (we declare none), a stale self-start intent from a
destroyed instance. Three presses qualify for none of it and would each take
the app down about ten seconds later: a key into a process the system built
*for* that key; a key on a card paused longer than ten minutes, once the tail
has run out and the press does not start playback; and any key at all with the
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
alive for. So
`onStartCommand` promotes first, before `super` — with media3's own card
through media3's own `onUpdateNotification(session, true)` when there is
something on the bar, so its `startedInForeground` bookkeeping stays true and
the shade never sees a stand-in, and otherwise with a plain notification on an
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
`startForeground` and `stopForeground` are **final**, so none of this can be
done by watching them; the split above is what keeps it from fighting the
library, because media3 cannot be in the foreground with nothing on the bar —
an empty timeline fails `shouldShowNotification`, which removes the
notification and demotes.
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
hands its `android/` Gradle module to tauri-build (which writes it into
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
and exposes `update` / `clear`, and the Kotlin side is three files — the
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
needs a media3 session to browse, and the rounds after this one build on the
Nothing in this plugin starts the service. It is **bound**, and that is half
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
action, a custom action, or media3's own self-start intent — which it
recognises by a uid extra of its own making — and returns `START_STICKY`
without calling `startForeground()` for anything else; a plain
`Intent(ctx, MediaService::class.java)` is anything else. So the old
`startForegroundService` would now arm Android's ten-second timer and satisfy
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
whole app about ten seconds after a foreground start that never called
`startForeground()`, and on that day the first real Save died with the
process, four tracks in. media3 promotes ITSELF, once there is something to
promote for — `MediaNotificationManager.startForeground()` does the
`startForegroundService` and the `setForegroundServiceNotification` in one
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
with the action `androidx.media3.session.MediaSessionService`, which creates
the service, is not subject to the Android 8+/12+ background-start
restriction, and arms no timer at all. The check is unchanged and one step
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
The other half is `MediaService.onStartCommand`, because the **system** starts
this service in the foreground whether media3 then wants it there or not.
media3 hands the framework a media-button `PendingIntent` built with
`PendingIntent.getForegroundService` — unconditionally, for every key.
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
falls to the `androidx.media3.session.MediaSessionService` filter in the
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
`setStartAsForegroundService(!player.playWhenReady)`, but
`PlaybackPendingIntentBuilder.build()` only honours it for
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
anyway; every other card button is a plain `getService`. The KEYS are the
problem. A key press arms the ten-second timer, and media3 promotes only for a
session it calls *user-engaged*: `playWhenReady` and
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
cannot resolve — a null session from `onGetSession` (ours never is), a custom
action it cannot name (we declare none), a stale self-start intent from a
destroyed instance. Three presses qualify for none of it and would each take
the app down about ten seconds later: a key into a process the system built
*for* that key; a key on a card paused longer than ten minutes, once the tail
has run out and the press does not start playback; and any key at all with the
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
alive for. So
`onStartCommand` promotes first, before `super` — with media3's own card
through media3's own `onUpdateNotification(session, true)` when there is
something on the bar, so its `startedInForeground` bookkeeping stays true and
the shade never sees a stand-in, and otherwise with a plain notification on an
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
`startForeground` and `stopForeground` are **final**, so none of this can be
done by watching them; the split above is what keeps it from fighting the
library, because media3 cannot be in the foreground with nothing on the bar —
an empty timeline fails `shouldShowNotification`, which removes the
notification and demotes.
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
hands its `android/` Gradle module to tauri-build (which writes it into
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
and exposes `update` / `clear`, and the Kotlin side is three files — the
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
needs a media3 session to browse, and the rounds after this one build on the
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
list and open the app at all, and `SoulStreamPlayer` is a
`SimpleBasePlayer` — media3's
supported way to put a non-ExoPlayer engine behind a session, which is exactly
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
last `NowPlayingArgs` off the bridge, queue window included) and every
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
`SimpleBasePlayer` — media3's
supported way to put a non-ExoPlayer engine behind a session, which is exactly
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
last `NowPlayingArgs` off the bridge, queue window included) and every
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
`src/mobile/media.svelte.js`, which did not change.
The session, the player, the notification provider, the audio-focus request
@@ -873,75 +873,75 @@ dies; the walk's `one-session` check is what keeps that honest.
`MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs
`auto service destroyed`. Those two lines are the only way to count the loop
on a real phone — see "### The Auto prober".
Four things in that file are load-bearing and silent when they break. The
playlist **must never be empty while a track exists**:
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
and the card then never appears, with nothing in any log — so `getState()`
falls back to a one-item playlist rather than trusting the window. Today's
frontend cannot actually send an empty window with a track on it
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
there because a frontend out of step with this plugin, or a `queue` that did
not survive the two JSON hops, would take the card down silently.
`COMMAND_GET_TIMELINE` and
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
down to what its commands allow — one window without the first,
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
same refusal from the other end. A paused player must report `STATE_READY`,
not `STATE_IDLE`, or the card
goes when the music does. And both spellings of next and previous must be
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
alone and the button appears and does nothing.
Two things media3 does NOT do for a custom player, whatever its own API
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
wording is "true if **the player** should handle audio focus".
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
"another app takes the output → we pause" and "headphones out → we pause",
and no walk on this phone can catch either.
The transport handlers do not answer media3 immediately. A play, a pause or a
seek is a round trip out to the WebView and back, and an instant answer has
media3 call `getState()` on the spot — which still reports the OLD state, so
the lock screen flips back before it flips forward. Each returns an
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
while one is outstanding media3 shows its own optimistic placeholder, which
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
regardless, because `invalidateState()` deliberately does nothing while
anything is pending: a future that is never completed does not degrade the
card, it freezes it.
Two behaviours changed with the migration, both defensible, both of them
looking like bugs if nobody wrote them down. After a pause the service stays
in the foreground for up to ten minutes (media3's
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
at once. And `clear` leaves the service alive with an idle session registered
rather than stopping it, so a headset button still reaches us with nothing on
the bar; the audio focus the old service gave back by dying is now abandoned
by hand in `MediaHub.clear`.
What did NOT change, though it is the first thing a reading of the API
suggests: previous and next still work everywhere the old card did, including
at the very top of a queue. The card's buttons are delivered as key events —
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
`BasePlayer` falls back to seeking the current item when there is no previous
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
else. So the app's own `prev()` — five-second restart rule and all — runs on
every press. The one button that really can go missing is next on the last row
with repeat off, because `hasNext` is false there and the command is not
on a real phone — see "### The Auto prober".
Four things in that file are load-bearing and silent when they break. The
playlist **must never be empty while a track exists**:
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
and the card then never appears, with nothing in any log — so `getState()`
falls back to a one-item playlist rather than trusting the window. Today's
frontend cannot actually send an empty window with a track on it
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
there because a frontend out of step with this plugin, or a `queue` that did
not survive the two JSON hops, would take the card down silently.
`COMMAND_GET_TIMELINE` and
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
down to what its commands allow — one window without the first,
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
same refusal from the other end. A paused player must report `STATE_READY`,
not `STATE_IDLE`, or the card
goes when the music does. And both spellings of next and previous must be
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
alone and the button appears and does nothing.
Two things media3 does NOT do for a custom player, whatever its own API
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
wording is "true if **the player** should handle audio focus".
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
"another app takes the output → we pause" and "headphones out → we pause",
and no walk on this phone can catch either.
The transport handlers do not answer media3 immediately. A play, a pause or a
seek is a round trip out to the WebView and back, and an instant answer has
media3 call `getState()` on the spot — which still reports the OLD state, so
the lock screen flips back before it flips forward. Each returns an
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
while one is outstanding media3 shows its own optimistic placeholder, which
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
regardless, because `invalidateState()` deliberately does nothing while
anything is pending: a future that is never completed does not degrade the
card, it freezes it.
Two behaviours changed with the migration, both defensible, both of them
looking like bugs if nobody wrote them down. After a pause the service stays
in the foreground for up to ten minutes (media3's
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
at once. And `clear` leaves the service alive with an idle session registered
rather than stopping it, so a headset button still reaches us with nothing on
the bar; the audio focus the old service gave back by dying is now abandoned
by hand in `MediaHub.clear`.
What did NOT change, though it is the first thing a reading of the API
suggests: previous and next still work everywhere the old card did, including
at the very top of a queue. The card's buttons are delivered as key events —
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
`BasePlayer` falls back to seeking the current item when there is no previous
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
else. So the app's own `prev()` — five-second restart rule and all — runs on
every press. The one button that really can go missing is next on the last row
with repeat off, because `hasNext` is false there and the command is not
advertised at all, which is correct.
The glyph in the status bar is the falcon, not a note.
@@ -1073,16 +1073,16 @@ FAILS rather than skips when it cannot be armed — a queue under three rows, or
repeat-one, under which `next()` seeks instead of stepping — with the reason
in the message.
### What the walk proves about the card, and what it does not
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
one card, on the `playback` channel, carrying the playing track's own title
(`media-card`, `one-card`); the system holding one media session for the app,
PLAYING (`media-session`, `one-session`); media keys reaching the player
through that session with the app in front (`media-keys`); ten seconds in the
background with the position still advancing (`background`); the paused
restore waited out past 25 s with the pid intact and no
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
### What the walk proves about the card, and what it does not
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
one card, on the `playback` channel, carrying the playing track's own title
(`media-card`, `one-card`); the system holding one media session for the app,
PLAYING (`media-session`, `one-session`); media keys reaching the player
through that session with the app in front (`media-keys`); ten seconds in the
background with the position still advancing (`background`); the paused
restore waited out past 25 s with the pid intact and no
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
queue window arriving across the bridge, field for field (`queue-window`);
and both of the intent filters Android Auto knocks on — the legacy
`android.media.browse.MediaBrowserService` and media3's own
@@ -1103,19 +1103,212 @@ last resolved it (`auto-desc`).
**The browse tree is not proven here and cannot be.** With the app
force-stopped there is no WebView, so no DevTools socket, so `smoke.mjs` has
nothing to attach to; `tools/auto-prober/` is the instrument for that, and
"### The Auto prober" under Verifying is where it lives.
**Kyle's hands only. Nothing here can be automated, and the walk going green
says nothing about any of it.** Unplugging headphones pauses the music —
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
audio focus pauses this one, and giving it back resumes it. The card's LOOK:
the cover, and the falcon in the status bar — `dumpsys notification` prints a
resource id and never a pixel, so a correct glyph and a white rectangle read
identically. And a Bluetooth headset's own buttons, which take a path
`input keyevent` does not. These four are the first things to try after the
"### The Auto prober" under Verifying is where it lives.
**Kyle's hands only. Nothing here can be automated, and the walk going green
says nothing about any of it.** Unplugging headphones pauses the music —
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
audio focus pauses this one, and giving it back resumes it. The card's LOOK:
the cover, and the falcon in the status bar — `dumpsys notification` prints a
resource id and never a pixel, so a correct glyph and a white rectangle read
identically. And a Bluetooth headset's own buttons, which take a path
`input keyevent` does not. These four are the first things to try after the
first deploy of anything that touches this plugin.
## Android Auto: cold playback, resume, and what the car is told
The car binds `MediaService` with the app COLD — no Activity, no WebView, no
`AppHandle` — and everything below is about making that process play what
it can, say what it cannot, and pick up where the phone left off. Four
pieces, all of them landed 2026-09-18:
**What plays cold: everything, the way the page plays it.** Library tracks
always did. A playlist's streamed rows play cold too — every one of them.
The page's memory of which peer file a catalog track resolved to
(`soulstream.streamSources.v1` in localStorage) is mirrored to
`<data>/stream-sources.json` (`stream_sources.rs`) — one entry every time a
source is remembered, and the whole store once at boot for memories made
before the mirror existed — and `auto_browse.rs` looks a streamed row up
there and computes its cache file with the same hash `playback::cache_path`
uses. A row whose file is whole plays from it and carries the host's
"downloaded" mark; a row whose bytes are not here is still a row that
plays, and the difference is only that mark. `resolve_playable` queues the
WHOLE playlist in order (`headless::PlayTrack.path` is `None` for a row
not here, `stream` its remembered peer file), and the engine's queue
(`headless.rs`) works like the page's: a row is FOUND when the queue
reaches it — the remembered peer file first, else one search through the
cold Soulseek session (`headless_slsk.rs`: the stored account, the
server's excluded phrases stripped, copies ranked close to
`songCandidates`, the best three tried with a fifteen-second first-byte
watchdog) — the bytes land in the stream cache and play as they arrive,
the next THREE rows are fetched ahead (`PREFETCH_DEPTH`, after the page's
four-second start delay), and when the immediate next is whole before the
current one ends it is appended to the sink for a gapless boundary
(`Queue.armed`, checked again at the boundary). A row nothing on the
network has moves the queue on, the page's rule; only the LAST row's
failure reaches the card. The card shows the title at once with a
BUFFERING spinner (`headlessState`) and the track once bytes play. One
Soulseek session per process: when the app opens over a car session,
`login_inner` ADOPTS the client (`headless_slsk::adopt` + `subscribe`)
rather than signing in again — two logins from one address in quick
succession is what the server throttles — and the fetches in flight become
the page's own preloads (`PlayerState.preloads`), so the row the car was
fetching ahead lands gaplessly under the page too. A playlist's subtitle
counts cached streams as "here". A tap while the app is UP is still handed
to the page (`AutoNative.describe` → `playMediaId`), which owns the queue
then.
**What the card says while a row is found.** Reaching a row whose bytes
are not here used to read as the player going to the next track and
freezing — and, after a skip, the OLD song kept sounding under the new
title until the fetch delivered. Now `start_at` stops the engine before
it looks (silence, not the wrong song), the card is built BUFFERING from
its first frame (`headlessTrack` `resolving`), and the engine narrates on
the card's artist line (`NowPlayingArgs.status`, sent through
`headlessState`): "Signing in to Soulseek…", "Searching Soulseek…",
"3 copies · connecting to <peer>…", "Streaming from <peer>…", "<peer> isn't
sending · trying another copy…", "No copy found · trying the next track…".
The engine's own Playing event clears the line and the artist returns. A
row fetched ahead says "Streaming from <peer>…" for the beat it takes.
**Covers, always.** The car draws the app icon over a row with no cover,
and thirty of GhettoTechie's forty rows had none — pasted-tracklist rows
the catalog never matched. Two hands fill them. In the cold process,
`auto_browse::embedded_cover` extracts the picture from the file's own tag
(the desktop's `tagger::extract_art`), keyed by the AUDIO path under
`<library>/.soulstream/stream-art/by-file/` with a miss remembered as an
empty file, for browse rows, the Continue row and the card; a row fetched
cold gets the same once 200 kB of it have landed (`art_after_bytes`,
re-notifying the card). On the phone, `coverart.svelte.js` is the desktop's
playlist backfill as a background WALK over every playlist: the file before
the catalog (`cachedStreamSourceFor` + `stream_embedded_art`, persisted as
the row's `art_path`), then the ONE strict ladder the import review uses
(`findCatalogTrack`, persisted as `art_url` + `track_id`) — never a loose
name lookup, a blank tile beats another record's cover. It runs 25 s after
boot, again when a playlist changes, every six hours, one row at a time,
and the open playlist page's rows go first; a miss is remembered for a day
in localStorage, only when the catalog answered. The player's
`ensureStreamArt` does the same for the track that is playing (embedded,
then catalog), makes the URL the entry's art key, and hands what it found
to the walk to persist. Because everything lands on the ROW, the car —
which reads rows straight out of the database — has it on the next browse.
And a car that starts the app COLD has no page to walk for it, so the
cold process asks the catalog itself: `cover_hunt.rs` is `catalogMatch.js`
in Rust (the same Deezer field queries through the metadata crate, a match
only when the artist agrees, the base title agrees, the version qualifiers
are compatible and a known duration does not contradict — keep the two in
step), fed by every browse and every queue built from a playlist: a blank
stream row is answered from `stream-art/catalog.json` when it has been
asked before, else queued for one thread that looks rows up one at a
time behind the metadata client's own Deezer pacing, writes the answer on
the row (`art_url`, and the track id; only a NULL is ever filled — the
library is WAL, the page's writer is another connection), and re-lists
the playlist on the car (`AutoNative.headlessCovers` →
`notifyChildrenChanged`). The track that is playing gets the same through
`art_from_catalog` / `art_after_bytes` and `AutoNative.headlessArt`, which
moves only the art, never the position. The cold Soulseek search is the
page's two-rung ladder too: artist + simplified title, then the title
alone with the artist required in the peer's path. The hunt's last rung is
the page's last rung as well — the artist's whole shelf flattened to
tracks (`catalog_artist_track_candidates`), for the records the search
index buries: Deezer's search answers "Nirvana Smells Like Teen Spirit"
with six live takes and never the studio cut, while Nevermind's tracklist
has it. That rung opens up to thirty tracklists behind the Deezer pacing,
so it is spent only on the track that is PLAYING (`lookup(…, deep)`),
bounded to forty seconds (a miss cut short by the clock is asked again
within the hour, not the day), and never on a browse row — the search
rungs answer those in half a second, and the phone's own walk, which does
run the shelf rung, writes what it finds on the row for the car to read.
The cover memory carries the matcher's version (`HUNT_VERSION`): a "no"
remembered by an older matcher is no answer once a rung is added.
**A trap that cost the first cold fetch.** The Soulseek client spawns a
download's transfer task with `tokio::spawn`, which needs a runtime on the
CALLING thread. The engine's resolver thread is not a Tauri command; the
spawn panicked, the thread died without a word (a cold process has no
stderr in logcat), and the card sat on BUFFERING for as long as anyone
looked. Every client call from an engine thread now runs under
`headless_slsk::rt().enter()`.
**The artist page's MusicBrainz pass.** The phone built its artist pages
from Deezer's list alone (`catalog_artist`), so the releases Deezer's
artist listing omits — EPs, and whole albums (My Bloody Valentine had four
releases) — never appeared, and neither did the authoritative release
types or the guest-appearance / co-tenant arbitration. The desktop's
identity merge now lives in `src/lib/artistIdentity.js` (moved out of
`state.svelte.js` verbatim; the desktop imports it back) and
`catalog.svelte.js` runs it after the page is on screen
(`applyIdentity`: `catalog_artist_identity`, one retry after eight seconds
on an empty answer, then `mergeArtistIdentity` into the page the screens
already read). One implementation for both surfaces, on purpose.
**Keep offline.** A playlist's More sheet has "Keep offline"
(`src/mobile/offline.svelte.js`, the list in `config.json`
`offline_playlists`): every streamed row is fetched into the stream cache in
the background — the remembered peer file first, else the resolver's ladder,
then the same `preload_stream` the player uses for the next track, one row
at a time, polled until whole — and pinned (`keep` in the mirror), which the
cache pruner honours, so the retention window cannot undo it. The fetch
rides the downloads notice in the shade, and that is also what keeps the
process alive once the app leaves the screen; Android 12+ lets that service
start only from the foreground, so a pass that begins on screen keeps going
in a pocket. The playlist header says "Kept offline · N of M here" once a
pass has looked.
**Resume, like every other player.** Where playback was is written to
`<data>/resume.json` (`resume.rs`) by two hands: the page names the queue
(a window of 25 either side of the current track, each entry by the file
that plays it cold — a local path, or the peer file whose cache may hold it)
every time it writes its own session, and the engine's pump keeps the
position, every five seconds while playing and at once on a pause or seek.
Three things read it. media3's `MediaButtonReceiver` is now declared, so a
PLAY key into a dead app — a Bluetooth head unit's button, the Quick
Settings resume card, Android Auto's autoplay on connection — starts the
service and lands in `LibraryTree.onPlaybackResumption`, which answers with
the `ss:resume` row and the position; media3 sets it, and
`play_media_id("ss:resume", startMs)` starts the saved queue at the saved
entry and seeks. A host asking for the RECENT root (`EXTRA_RECENT`) gets a
root whose one child is that row, "Continue · <record> · m:ss". And the
Recent tab lists it first. An entry whose file is gone is skipped; if the
one that was playing is gone, the next playable one starts at zero rather
than three minutes into a different song.
**The handover.** When the app opens over a car session the engine is
already playing a queue the page has never heard of. `playback::init` takes
that queue off the headless engine before clearing it, `player_state` hands
it over once, and `restoreSession` builds the bar from it — instead of
restoring the page's own older session over live audio, which put the wrong
title on the bar.
**What the car is told.** A tap that cannot play — a streamed row no peer
on the network has, a hand-off the resolver refused, a row whose file is
gone — used to be silence. `MediaHub.fail` now puts the reason
in the playback state (`STATE_ERROR` with the message, which Auto draws as a
card) and sends a `SessionError` carrying "Open SoulStream" as the
resolution action; the next track that plays withdraws it. The page reports
its own verdict back through `remote_failed` when a hand-off fails.
**The notification storm, found and fixed.** 2026-09-18's logcat had the
card re-posted six times a second for over a minute, then "Too many
PendingIntent created" (media3 mints a fresh one for the custom button on
every rebuild) and twenty refused foreground starts in two hundred
milliseconds. The cause was a loop between `MediaService.onStartCommand` and
media3: media3 starts the service ITSELF on every foreground notification
update (`MediaNotificationManager.startForeground` →
`startForegroundService(startSelfIntent)`, a bare component intent with a
uid extra and nothing else), and `onStartCommand` promoted on every intent
— re-posting the card, which made media3 start the service again. It ran
for as long as the service was in the foreground: while a track played, and
for the ten-minute tail after it paused (the capture shows it stopping at
exactly pause + 10 min). `onStartCommand` now promotes only for an intent
that carries an action or a data uri — a media key, a card button — and
leaves media3's self-start to media3. Separately, every `refresh()` of the
media3 player goes through `MediaHub.refreshSoon`, which coalesces asks
inside a 250 ms window and logs `card asked to rebuild Nx in 10s` when the
rate is silly; it stayed silent through the storm, which is what pointed
away from our own rebuilds and at the service restart.
## What a screen reader says
Every control on the phone is named by what it does, in the app's own
@@ -1186,6 +1379,26 @@ would notice.
## When things go wrong
**The network comes back on its own (2026-09-18).** A Soulseek server
connection dies for reasons that are nobody's: the phone changes networks,
Android freezes a backgrounded process for long enough that the five-minute
keepalive never goes out and the server closes the socket, a laptop sleeps.
Until now every one of those ended as `Event::Disconnected`, the page stamped
"offline", and every streamed track after that was refused with "sign in on
Community" until somebody opened the login card — in a car, nobody does.
`commands::schedule_reconnect` now dials the dropped session back with the
stored credentials: 5 s, 15 s, 45 s, 2 min, then every 5 min, with jitter,
which stays well under the server's rapid-login throttle (eight to ten and it
silently drops the handshake). The page hears `soulseek:state { kind:
"reconnecting", attempt, in_secs }` and shows "connecting" rather than
"offline"; the resolver waits up to twenty seconds for a dial in progress
before refusing a track. An explicit Disconnect ends the wish; an explicit
login starts a new generation, and a drop reported by an older session's
pump — which used to stamp a fresh session offline, a race that was there
all along — now says nothing. Debug builds expose `debug_drop_connection`
(`__ss.api.debugDropConnection()`), which severs the socket exactly the way a
network drop would, so the redial can be watched.
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
a dead cell, or holding a library folder that has gone away, and until this
round every one of those showed up as an empty box, a spinner with no end, or
@@ -2413,27 +2626,16 @@ symptom.
the share sheet, which no walk reaches. Work down the smoke list in "The
release build" before trusting a release APK on a phone.
- **Android Auto can see the app, and there is nothing inside.** Round
forty-nine made `MediaService` a `MediaLibraryService`, put three actions
on one intent filter (media3's `MediaLibraryService` and
`MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds), and
added `res/xml/automotive_app_desc.xml` with the
`com.google.android.gms.car.application` meta-data that declares the app to
Auto. So Auto lists SoulStream, opens it, and gets a root — immediately,
from `Futures.immediateFuture`, with nothing on the main thread — and the
root is empty. `onGetChildren` is not overridden, so the default answers
`ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in
it. What is still missing is everything under it: the library read out of
the database **read-only** from a process with no Activity and no Tauri
(`crates/library` is plain rusqlite and `JNI_OnLoad` publishes the Context
at `System.loadLibrary` time, so browse can work cold), playing what it
lists — which needs `COMMAND_SET_MEDIA_ITEM` and its neighbours, still
deliberately unadvertised, plus the `queueBase` → `playQueueAt()` mapping —
and search. Catalog and Soulseek rows will never play from cold: the
resolver is 872 lines of shared JS and Soulseek needs a live session, so
they get `STATE_ERROR` with a resolution intent instead, which is also what
Google's quality bar asks for. The walk proves the two front doors resolve
(`auto-browse`, `auto-library`) and `tools/auto-prober/` proves the root
comes back cold; the car screen itself still needs the Desktop Head Unit or
a car.
- **Android Auto, what is still not there.** The tree, cold playback of
library tracks and cached streams, search and voice, resume and autoplay,
and the error card are in (see "Android Auto: cold playback, resume, and
what the car is told"). Not yet: a streamed row with NO cache copy still
cannot play from a cold process — the resolver is shared JS and Soulseek
needs a live session — and the car says so and offers to open the app;
"Keep offline" is the answer for a playlist that matters. **Save**, the
design's second transport action, is unbuilt. The car's own plays are not
written to `plays` (the headless process opens the library read-only by
rule), so a record heard only in the car does not climb the phone's Recent
— the resume row covers the common case. Catalog browsing beyond
playlists, the queue as a browsable list, and the `queueBase` →
`playQueueAt()` mapping for tapping a queue row are still absent.
@@ -49,6 +49,26 @@
</intent-filter>
</service>
<!-- media3's own media-button receiver, and the reason the app can
start COLD from a PLAY key: a Bluetooth head unit's play button,
the Quick Settings "resume" card after a reboot, and Android
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
at a process that does not exist. The receiver starts the
service in the foreground (MediaService.onStartCommand promotes
at once, see holdForeground) and media3 then asks
LibraryTree.onPlaybackResumption what to play. It forwards
ONLY play / play-pause / headset-hook into a dead app — a next or
a pause into nothing is dropped, not started. While the app is
alive the framework talks to the session directly and the
receiver is not involved. -->
<receiver
android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
@@ -114,7 +114,25 @@ import org.json.JSONArray
@OptIn(markerClass = [UnstableApi::class])
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
/**
* Play a browse row THROUGH THE APP: the page owns the queue while it
* exists (shuffle, repeat, failover, the resolver, the session it
* restores), so every car tap is handed to it whole — a library album, a
* playlist row, the resume row — rather than started on the engine
* underneath it, which left the bar naming one track while another
* played. The first argument is the row as `AutoNative.describe` spells
* it for the page; the second the position to start at, or -1.
*
* Non-null ONLY while the plugin is loaded, which means only while a
* WebView exists. That is exactly the test for "is the app warm", so it
* doubles as the discriminator: set, and the car's tap becomes the app's
* tap; null, and the engine in this process plays what it can itself.
*/
@Volatile var onPlay: ((String, Long) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
/** Why the last thing asked for did not play, for the car screen; null
* the moment anything plays. See [fail]. */
@Volatile var error: String? = null
/** the cover as ENCODED bytes — media3 decodes them itself */
@Volatile var artBytes: ByteArray? = null
@Volatile var artKey: String? = null
@@ -189,6 +207,24 @@ object MediaHub {
}
}
state = np
/* something is on the bar again: whatever went wrong last is over —
and the error a legacy controller was SENT is withdrawn explicitly,
because a head unit keeps showing one until told otherwise */
val hadError = error != null
error = null
if (hadError) {
mainHandler.post {
try {
libSession?.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.INFO_CANCELLED, "",
),
)
} catch (_: Exception) {
/* a controller that has gone; nothing to withdraw from */
}
}
}
bind(app)
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
verifyApplicationThread(), a Tauri @Command is not promised to run
@@ -201,9 +237,108 @@ object MediaHub {
because the update that MADE the service found `service` still
null; there is nothing to catch up now. */
if (np.playing) requestFocus(app)
player?.let {
it.settle()
it.refresh()
player?.settle()
refreshSoon()
}
}
/* ── the card's rebuild rate ─────────────────────────────────────────
Every refresh() is a getState(), a diff, a PlayerInfo to every
connected controller (seventeen of them on 2026-09-18, after a day
of head-unit sessions), and — when anything the card shows moved — a
notification re-post, which under media3 mints a fresh PendingIntent
for the custom button each time. This morning's logcat had the card
re-posted six times a second for over a minute ("Package enqueue
rate is 6.6 … Shedding"), then "Too many PendingIntent created for
uid", then twenty refused foreground starts in two hundred
milliseconds. Whatever asks that often, the answer is the same: the
card is a mirror, and a mirror that is repainted more than a few
times a second is not showing anything new. So refreshes are
COALESCED — the first lands at once, anything inside the window
folds into one rebuild at the window's end — and counted, so the
next storm has a number beside it in the log instead of a mystery. */
private const val REFRESH_WINDOW_MS = 250L
@Volatile private var refreshPending = false
@Volatile private var lastRefreshAt = 0L
private var refreshes = 0
private var refreshAsks = 0
private var rateSince = 0L
private val refreshNow = Runnable {
refreshPending = false
lastRefreshAt = android.os.SystemClock.uptimeMillis()
refreshes += 1
player?.refresh()
}
/** Ask for a state rebuild. Main thread only. */
fun refreshSoon() {
val now = android.os.SystemClock.uptimeMillis()
refreshAsks += 1
if (rateSince == 0L) rateSince = now
if (now - rateSince >= 10_000L) {
if (refreshAsks > 20) {
android.util.Log.w(
"SoulStream",
"card asked to rebuild " + refreshAsks + "x in 10s (" + refreshes + " done)",
)
}
refreshAsks = 0
refreshes = 0
rateSince = now
}
if (refreshPending) return
val since = now - lastRefreshAt
if (since >= REFRESH_WINDOW_MS) {
refreshNow.run()
} else {
refreshPending = true
mainHandler.postDelayed(refreshNow, REFRESH_WINDOW_MS - since)
}
}
/**
* Something the car (or a headset) asked for could not play: the row
* needs the app and the app is not up, the resolver found no source,
* the network is signed out. Until now every one of those was silence
* on the head unit. This puts the reason where a driver looks — the
* playback state goes to error with the message, which Android Auto
* draws as a card — and offers "Open SoulStream" as the way out, the
* resolution the design asked for.
*
* Cleared by the next [update]: a track playing is the error being over.
*/
fun fail(ctx: Context, message: String) {
val app = ctx.applicationContext
error = message
android.util.Log.i("SoulStream", "auto error shown: " + message)
mainHandler.post {
refreshSoon()
val s = libSession ?: return@post
try {
val extras = android.os.Bundle()
extras.putString(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT,
"Open SoulStream",
)
app.packageManager.getLaunchIntentForPackage(app.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
extras.putParcelable(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT,
PendingIntent.getActivity(
app, 7, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
s.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.ERROR_INVALID_STATE,
message,
extras,
),
)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "auto error could not be sent: $e")
}
}
}
@@ -222,10 +357,8 @@ object MediaHub {
artKey = null
mainHandler.post {
dropFocus(app)
player?.let {
it.settle()
it.refresh()
}
player?.settle()
refreshSoon()
}
}
@@ -478,6 +611,14 @@ object MediaHub {
fun act(action: String, position: Double = -1.0) {
onAction?.invoke(action, position)
}
/** True when the app is up: the page owns the queue and plays every tap. */
fun warm(): Boolean = onPlay != null
/** Hand a row to the app to play, as `AutoNative.describe` spelled it. */
fun play(desc: String, startMs: Long) {
onPlay?.invoke(desc, startMs)
}
}
/**
@@ -575,9 +716,90 @@ object LibraryTree : MediaLibrarySession.Callback {
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST)
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
val rootParams = LibraryParams.Builder().setExtras(b).build()
// The RECENT root is a different question — "what would you continue?"
// — asked by Android's own resumption surfaces and by Auto. It is
// answered with a root whose one child is the resume row
// (auto_browse.rs RECENT_ROOT), read out of resume.json and never the
// database; a host that gets the library tree here would draw four
// tabs where it wanted one song.
if (params?.isRecent == true) {
return Futures.immediateFuture(LibraryResult.ofItem(recentRootItem(), rootParams))
}
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams))
}
/** The id auto_browse.rs answers the resume row for. */
const val RECENT_ROOT = "ss:recent-root"
private fun recentRootItem(): MediaItem =
MediaItem.Builder()
.setMediaId(RECENT_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Continue")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
/**
* "Pick up where you left off", asked by a PLAY that reaches a player
* with nothing in it: a Bluetooth head unit's button, the Quick Settings
* resume card, Android Auto's autoplay on connection. media3 takes what
* comes back, sets it on the player, prepares and plays — so this
* answers with the resume row (`ss:resume`) and the position, and
* [SoulStreamPlayer.handleSetMediaItems] turns that into the saved queue
* at the saved spot. `isForPlayback` false is the same question asked to
* DRAW a card; the answer is identical and nothing starts.
*
* Off the calling thread: it reads resume.json and stats a few files.
*/
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
isForPlayback: Boolean,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.submit<MediaSession.MediaItemsWithStartPosition>(
{
val ctx = MediaHub.appContext ?: throw IllegalStateException("no context")
val o = org.json.JSONObject(AutoNative.resumeInfo(ctx.dataDir.absolutePath))
if (!o.optBoolean("ok", false)) {
android.util.Log.i("SoulStream", "auto resume asked, nothing to resume")
throw UnsupportedOperationException("nothing to resume")
}
val md = MediaMetadata.Builder()
.setTitle(o.optString("title"))
.setArtist(o.optString("artist"))
.setAlbumTitle(o.optString("album"))
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
artUri(ctx, art, controller.packageName)?.let { md.setArtworkUri(it) }
val item = MediaItem.Builder()
.setMediaId(o.optString("id", "ss:resume"))
.setMediaMetadata(md.build())
.build()
val pos = o.optLong("positionMs", 0L)
android.util.Log.i(
"SoulStream",
"auto resume " + (if (isForPlayback) "for playback" else "to show") +
": " + o.optString("title") + " at " + pos + "ms",
)
MediaSession.MediaItemsWithStartPosition(ImmutableList.of(item), 0, pos)
},
io,
)
/** The older spelling of the same question, for a host on the old path. */
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
onPlaybackResumption(mediaSession, controller, true)
/**
* The icon a root tab is drawn with — the thing that turns four words into
* four tabs with pictures over them, which is what every other media app
@@ -607,7 +829,15 @@ object LibraryTree : MediaLibrarySession.Callback {
* the hint itself. Albums are sleeves — the tab that lists them, and each
* artist whose children are albums. */
private fun styleExtras(id: String): android.os.Bundle? {
val grid = id == "ss:albums" || id.startsWith("ss:artist:")
// Anything whose children are SLEEVES. Recent is albums and
// playlists, a playlist is covers per track, and an artist's children
// are records — all of them are known by the picture, which is the
// whole reason the grid exists.
val grid =
id == "ss:albums" ||
id == "ss:recent" ||
id.startsWith("ss:artist:") ||
id.startsWith("ss:playlist:")
if (!grid) return null
val b = android.os.Bundle()
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
@@ -632,6 +862,31 @@ object LibraryTree : MediaLibrarySession.Callback {
* `page` and `pageSize` are ignored deliberately: Auto does not paginate
* a browse, it asks for a node and draws what comes back.
*/
/**
* A browser asking to be told when a parent's rows change. media3's
* DEFAULT answers "not supported", which means nobody is ever
* subscribed and `notifyChildrenChanged` reaches nobody — the cover
* hunt (cover_hunt.rs) found covers for a whole playlist and the car
* kept showing the blank list until it was left and re-entered
* (2026-09-18). Accepting is all it takes: the host re-lists the parent
* on every notice from then on.
*/
override fun onSubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> {
android.util.Log.i("SoulStream", "auto subscribe $parentId by " + browser.packageName)
return Futures.immediateFuture(LibraryResult.ofVoid())
}
override fun onUnsubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
): ListenableFuture<LibraryResult<Void>> = Futures.immediateFuture(LibraryResult.ofVoid())
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@@ -814,6 +1069,19 @@ object LibraryTree : MediaLibrarySession.Callback {
*/
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
if (path.isNullOrEmpty()) return null
// A REMOTE cover is already a uri and needs no provider and no grant —
// the host fetches it. Streamed playlist entries have no cover file at
// all, only a URL, so routing one through FileProvider would fail the
// root check and lose the art of every catalog row.
if (path.startsWith("http://") || path.startsWith("https://") ||
path.startsWith("content://")
) {
return try {
android.net.Uri.parse(path)
} catch (e: Throwable) {
null
}
}
return try {
val uri =
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
@@ -917,7 +1185,21 @@ object LibraryTree : MediaLibrarySession.Callback {
val tab = tabIcon(ctx, id)
if (tab != null) md.setArtworkUri(tab)
else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
styleExtras(id)?.let { md.setExtras(it) }
val extras = styleExtras(id) ?: android.os.Bundle()
// A playlist row whose file is on this phone — a local track,
// or a streamed one whose cache copy is whole — wears the
// host's own "downloaded" mark. It is the design's reach
// signal turned the safe way round: the rows that play with
// the app shut are the marked ones, and the mark thins the
// "needs the app" rows out as more of the list is kept.
val here = id.startsWith("ss:pitem:") && !o.isNull("path") && o.optString("path").isNotEmpty()
if (here) {
extras.putLong(
androidx.media3.session.MediaConstants.EXTRAS_KEY_DOWNLOAD_STATUS,
androidx.media3.session.MediaConstants.EXTRAS_VALUE_STATUS_DOWNLOADED,
)
}
if (!extras.isEmpty) md.setExtras(extras)
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
}
} catch (e: Throwable) {
@@ -1096,8 +1378,27 @@ class MediaService : MediaLibraryService() {
stops. Promoting after that would put a service already on its way
out back into the foreground. A null intent is the system
re-creating a START_STICKY service — not a foreground start, and
it needs nothing. */
if (intent != null) holdForeground()
it needs nothing.
ONLY FOR AN INTENT SOMEBODY ELSE SENT — a media key, a card
button — which is one that carries an action or a data uri.
media3 starts this service ITSELF on every foreground notification
update: `MediaNotificationManager.startForeground` calls
`ContextCompat.startForegroundService(startSelfIntent)`, and that
intent is a bare `Intent(context, MediaService::class.java)` with
nothing but a uid extra — no action, no data. Promoting on THAT
re-posted the notification, which made media3 start the service
again, which landed here again: a loop at the rate two binder
calls allow, about six a second, for as long as the service was
in the foreground — while a track played, and for the ten-minute
tail after it paused. That is the whole of 2026-09-18's storm:
"Package enqueue rate is 6.6 … Shedding" for over a minute,
"Too many PendingIntent created for uid" (media3 mints a fresh
one for the custom button on every rebuild), and twenty refused
foreground starts in two hundred milliseconds once the app was
in the background. The self-start needs nothing from us: media3
promotes in the same breath it sends it. */
if (intent != null && (intent.action != null || intent.data != null)) holdForeground()
return super.onStartCommand(intent, flags, startId)
}
@@ -1221,9 +1522,18 @@ object AutoNative {
* left in silence.
*
* A tapped track brings its album with it, so the record keeps playing
* without any JS alive to advance it.
* without any JS alive to advance it. `startMs` below zero means "where
* it was left" for the resume row and the top for everything else.
*/
external fun play(dataDir: String, mediaId: String): String
external fun play(dataDir: String, mediaId: String, startMs: Long): String
/** What would resume, and where, as JSON (`{"ok":false}` when nothing
* can). Reads resume.json and stats a few files; starts nothing. */
external fun resumeInfo(dataDir: String): String
/** A browse row as the PAGE understands it — `{"kind":"album","albumId":n}`
* and its siblings — for the warm hand-off. Two lookups at most. */
external fun describe(dataDir: String, mediaId: String): String
/** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */
@@ -1281,6 +1591,96 @@ object AutoNative {
}
}
/**
* The fallback queue ran out — called FROM Rust, on the audio pump, like
* [headlessTrack]. The card goes to "paused, at the end": media3 was
* extrapolating a PLAYING position over silence for as long as anyone
* looked, because nothing had told it the engine stopped.
*/
/**
* A stream's arrival, called FROM Rust on the audio pump: "buffering"
* while the first bytes are on their way (the card shows a spinner),
* "playing" once they sound, "failed" with the reason when no copy ever
* delivered — which becomes the error card, not silence.
*/
@JvmStatic
fun headlessState(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val o = org.json.JSONObject(json)
val message = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
when (o.optString("state")) {
"buffering" -> MediaHub.state?.let {
it.buffering = true
if (message != null) it.status = message
MediaHub.update(ctx, it)
}
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; it.status = null; MediaHub.update(ctx, it) }
"failed" -> {
MediaHub.state?.buffering = false
MediaHub.state?.status = null
val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
MediaHub.fail(ctx, why ?: "SoulStream could not play that.")
}
}
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto state card failed: " + e)
}
}
@JvmStatic
fun headlessEnded(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val np = MediaHub.state ?: return
np.playing = false
np.duration?.let { np.position = it }
np.hasNext = false
MediaHub.update(ctx, np)
android.util.Log.i("SoulStream", "auto queue ended")
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto ended card failed: " + e)
}
}
/**
* The cover under the track that is showing changed — a picture pulled
* out of the file once enough of it landed, or the catalog's answer
* (cover_hunt.rs). Only the art moves; the position stays.
*/
@JvmStatic
fun headlessArt(json: String) {
try {
val ctx = MediaHub.appContext ?: return
val np = MediaHub.state ?: return
val o = org.json.JSONObject(json)
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } ?: return
np.artKey = art
np.art = artB64(art)
MediaHub.update(ctx, np)
android.util.Log.i("SoulStream", "auto cover landed for " + np.title)
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto art card failed: " + e)
}
}
/**
* Rows of a browse parent have covers they did not have when the car
* last listed it (cover_hunt.rs): tell the host to list it again.
* Int.MAX_VALUE is media3's own "count unknown".
*/
@JvmStatic
fun headlessCovers(json: String) {
try {
val parent = org.json.JSONObject(json).optString("parent").ifEmpty { return }
val session = MediaHub.sessionOrNull() ?: return
session.notifyChildrenChanged(parent, Int.MAX_VALUE, null)
android.util.Log.i("SoulStream", "auto covers landed, re-listing " + parent)
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto re-list failed: " + e)
}
}
@JvmStatic
fun headlessTrack(json: String) {
try {
@@ -1295,11 +1695,16 @@ object AutoNative {
np.playing = true
np.hasNext = o.optBoolean("hasNext", false)
np.hasPrev = true
/* a row whose bytes are not here yet: BUFFERING from the first
frame, with a line that says so, rather than "playing at
0:00" for a beat before the spinner */
np.buffering = o.optBoolean("resolving", false)
np.status = if (np.buffering) "Finding a copy…" else null
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
np.artKey = art
np.art = artB64(art)
MediaHub.update(ctx, np)
android.util.Log.i("SoulStream", "auto advanced to " + np.title)
android.util.Log.i("SoulStream", "auto advanced to " + np.title + (if (np.buffering) " (resolving)" else ""))
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
}
@@ -28,6 +28,16 @@ class NowPlayingArgs {
var art: String? = null
var hasNext: Boolean = true
var hasPrev: Boolean = true
/** the bytes are still on their way (a cold fetch from the network);
* the car shows a spinner rather than a position. Never sent by the
* page — its own player folds buffering into `playing`. */
var buffering: Boolean = false
/** what the engine is doing while `buffering` — "Searching Soulseek…",
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
* on the card's artist line until the bytes sound. A card that went
* to the next title and sat at 0:00 read as the player freezing
* (2026-09-18); this is the difference. */
var status: String? = null
/** the queue around the current item — it and up to ten either side.
* Since round forty-eight SoulStreamPlayer.getState() turns this into
* the media3 playlist the card is built from, and the index into it.
@@ -63,6 +73,13 @@ class QueueItemArgs {
var artKey: String? = null
}
/** Why a row the car handed to the page did not play — the Rust
* `RemoteFailure`, camelCase. */
@InvokeArg
class RemoteFailureArgs {
var message: String? = null
}
/**
* The bridge between the WebView and the service that owns the session.
* `update` mirrors the track on the bar (and starts the service the first
@@ -80,6 +97,32 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
payload.put("position", position)
trigger("action", payload)
}
/* The car asking the APP to play a row — any row, while the page is
up and owns the queue. Setting this is also what tells the media
service the app is warm — it is null whenever there is no WebView. */
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
MediaHub.onPlay = { desc, startMs ->
val payload = JSObject()
payload.put("action", "playMediaId")
payload.put("desc", desc)
payload.put("startMs", startMs)
trigger("action", payload)
}
}
/** The page is going away — the app swiped out of recents, say — while
* the media service may keep this process alive for the car. From here
* there is nobody behind `trigger`, so the hand-off must stop believing
* the app is warm: a tap in the car would otherwise be posted to a
* WebView that no longer exists and answered by nothing. Null means the
* engine in this process plays what it can and the car is told about the
* rest, exactly as on a cold start. A new Activity builds a new plugin,
* whose init arms both again. */
override fun onDestroy() {
MediaHub.onPlay = null
MediaHub.onAction = null
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
super.onDestroy()
}
@Command
@@ -115,6 +158,15 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
invoke.resolve()
}
/** The page could not play a row the car handed it. The session says
* why, on the car screen, instead of leaving the driver in silence. */
@Command
fun remoteFailed(invoke: Invoke) {
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
invoke.resolve()
}
/** Android 13+: the card is a notification and needs the permission; asked
* once, the first time something plays. Media controls in the shade and
* on the lock screen follow the session either way. */
@@ -55,6 +55,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the state media3 reads ──────────────────────────────────────── */
override fun getState(): SimpleBasePlayer.State {
/* Something asked for could not play, and the reason is what the
car should show rather than a blank player or the last track's
card. A player error is only legal in STATE_IDLE, and media3's
legacy stub turns exactly that into PlaybackStateCompat's
STATE_ERROR with the message — which Android Auto draws as a card
over the browse list. The playlist is kept (if there was one) so
the transport does not lose its shape; the next update clears it. */
MediaHub.error?.let { why ->
val err = androidx.media3.common.PlaybackException(
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
)
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlayerError(err)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
}
val np = MediaHub.state
?: return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
@@ -105,19 +123,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
per rebuild and is always true. */
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
var sounding = np.playing
var buffering = false
if (headless()) {
posMs = AutoNative.positionMs()
sounding = AutoNative.sounding()
/* a cold fetch whose first bytes are still on their way: the car
draws a spinner, and media3 does not extrapolate a position
that is not moving */
buffering = np.buffering && !sounding
}
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(np))
/* STATE_READY even when paused. shouldShowNotification treats
STATE_IDLE as "nothing to show" unless it has been prepared and
stopped, so a paused player reporting IDLE loses its card.
There is no buffering state to report either: media.svelte.js
already folds `buffering` into `playing`. */
.setPlaybackState(Player.STATE_READY)
.setPlayWhenReady(sounding, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
The page's own player folds buffering into `playing`; only the
headless fetch reports BUFFERING, above. */
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlaylist(items)
.setCurrentMediaItemIndex(at)
/* The long overload, not a PositionSupplier: State's own
@@ -165,9 +188,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
artKey: String?,
current: Boolean,
): SimpleBasePlayer.MediaItemData {
/* while the engine is finding the current row's bytes, its status
line rides the artist line — the one line the car shows under
the title */
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
val md = MediaMetadata.Builder()
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
.setArtist(artist ?: "")
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
.setAlbumTitle(album ?: "")
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -179,8 +206,20 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
lock screen shows a grey hole. media3 decodes these bytes
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
is one BitmapFactory call this file no longer makes. */
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
val bytes = MediaHub.artBytes
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
if (bytes != null) {
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (remote != null) {
/* A streamed record's cover is a URL and no bytes ever arrive
for it on the headless path — there is no page to decode
one. media3's own loader fetches a URI, so the car gets
the sleeve and the colour it washes the screen with,
instead of the app icon's blue. */
md.setArtworkUri(Uri.parse(remote))
} else {
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
}
} else if (!artKey.isNullOrEmpty()) {
/* A LOCAL path only. A catalog record's art key is an http URL,
and media3's default loader would happily fetch it — twenty
@@ -252,7 +291,15 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the transport, back out to the WebView ──────────────────────── */
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
if (headless()) {
android.util.Log.i(
"SoulStream",
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
)
if (headless() || !MediaHub.warm()) {
/* the engine's own queue, or a page that is gone (the app swiped
away under live audio): pause and resume belong to the engine
either way, and a message to a WebView that no longer exists
is the button doing nothing */
AutoNative.transport(if (playWhenReady) "resume" else "pause")
return Futures.immediateVoidFuture()
}
@@ -308,8 +355,26 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
val id = mediaItems.getOrNull(i)?.mediaId
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
// The position media3 asks for: a resumption carries the saved one, a
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
// left", which only the resume row has an answer to.
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
return Futures.submit<Unit>(
{
// THE APP IS UP: the page owns the queue, so the tap is the
// page's — whatever the row is. Starting it on the engine
// underneath the page instead (which is what this did for a
// library album while the page had a queue of its own) left
// two drivers on one engine: the bar naming one track while
// another played, and the next boundary decided by whichever
// got there first. The page asks for focus itself when it
// plays, and reports back through remote_failed if it cannot.
if (MediaHub.warm()) {
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
MediaHub.play(desc, startMs)
return@submit
}
// FOCUS FIRST, and it is not optional. Without it the track
// opens into a port the framework keeps muted: the first run
// of this path showed a live AAudio track on our uid with
@@ -317,14 +382,33 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
// audible to nobody. On the WebView path the JS asks for
// focus; a car tapping a cold process has no JS to do it.
MediaHub.requestFocus(ctx.applicationContext)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
if (queued == 0) {
// Nothing resolved: give the focus back rather than
// holding another app's audio hostage for silence.
// Nothing on this phone can play it, and the process is
// cold: the focus goes back rather than being held for
// silence — and the car is TOLD, which it never was
// before. A streamed row is fetched from the network by
// this process now (headless_slsk.rs); `error` is its
// reason when that could not even start.
MediaHub.dropFocus(ctx.applicationContext)
val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null }
if (why != null) {
android.util.Log.i("SoulStream", "auto cold fetch refused: $why")
MediaHub.fail(ctx.applicationContext, why)
} else if (o.optBoolean("remote", false)) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
MediaHub.fail(
ctx.applicationContext,
"This track streams from the network. Open SoulStream on your phone to play it here.",
)
} else if (id == "ss:resume") {
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
} else {
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
}
} else {
// Write the card ourselves. `getState()` mirrors
// MediaHub.state, and the only thing that ever fills it is
@@ -338,9 +422,18 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
np.artist = o.optString("artist").ifEmpty { null }
np.album = o.optString("album").ifEmpty { null }
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
np.position = 0.0
/* a resumed track starts where it was left; the engine's
own position takes over from the first tick */
np.position = o.optLong("positionMs", 0L) / 1000.0
np.playing = true
np.hasNext = queued > 1
/* a cold fetch: the title now, a spinner until the first
bytes play (headlessState turns it off) */
np.buffering = o.optBoolean("resolving", false)
/* the engine narrated inside the call that just returned
(headlessState ran before this line): keep its line,
or the first one, rather than the artist for a beat */
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
np.hasNext = o.optBoolean("hasNext", queued > 1)
np.hasPrev = false
// The cover, which is also the colour: Android Auto washes
// the playback screen with whatever artwork it is handed,
@@ -363,6 +456,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
}
override fun handleStop(): ListenableFuture<*> {
if (headless() || !MediaHub.warm()) {
AutoNative.transport("stop")
return Futures.immediateVoidFuture()
}
MediaHub.act("stop")
return awaitUpdate()
}
@@ -404,7 +501,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
if (headless()) {
if (headless() || !MediaHub.warm()) {
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
AutoNative.transport("next")
+4 -1
View File
@@ -3,7 +3,10 @@
// event. They are commands like any other to the ACL: without them here the
// listener was refused ("media-session.registerListener not allowed") and
// the card's buttons went nowhere.
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
// `remote_failed` is the RUST command's name, which is what the ACL keys on
// (the invoke is `plugin:media-session|remote_failed`); the Kotlin side's
// `remoteFailed` is reached through run_mobile_plugin and never appears here.
const COMMANDS: &[&str] = &["update", "clear", "remote_failed", "registerListener", "removeListener"];
fn main() {
// Registers the command permissions (permissions/autogenerated) and, on
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remote-failed"
description = "Enables the remote_failed command without any pre-configured scope."
commands.allow = ["remote_failed"]
[[permission]]
identifier = "deny-remote-failed"
description = "Denies the remote_failed command without any pre-configured scope."
commands.deny = ["remote_failed"]
@@ -9,6 +9,7 @@ hear the card's buttons (`addPluginListener`).
- `allow-update`
- `allow-clear`
- `allow-remote-failed`
- `allow-registerListener`
- `allow-removeListener`
@@ -76,6 +77,32 @@ Denies the registerListener command without any pre-configured scope.
<tr>
<td>
`media-session:allow-remote-failed`
</td>
<td>
Enables the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-remote-failed`
</td>
<td>
Denies the remote_failed command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-removeListener`
</td>
@@ -7,4 +7,4 @@ transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
"""
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
permissions = ["allow-update", "allow-clear", "allow-remote-failed", "allow-registerListener", "allow-removeListener"]
@@ -318,6 +318,18 @@
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "allow-remote-failed",
"markdownDescription": "Enables the remote_failed command without any pre-configured scope."
},
{
"description": "Denies the remote_failed command without any pre-configured scope.",
"type": "string",
"const": "deny-remote-failed",
"markdownDescription": "Denies the remote_failed command without any pre-configured scope."
},
{
"description": "Enables the removeListener command without any pre-configured scope.",
"type": "string",
@@ -343,10 +355,10 @@
"markdownDescription": "Denies the update command without any pre-configured scope."
},
{
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`"
}
]
}
+30 -1
View File
@@ -141,9 +141,38 @@ fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
}
}
/// What the page sends when a row the CAR handed it could not be played:
/// the resolver found nothing, or the network is signed out. The session
/// turns it into an error state the head unit can show, with the reason,
/// instead of the silence a driver was getting.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RemoteFailure {
pub message: String,
}
#[tauri::command]
fn remote_failed<R: Runtime>(app: tauri::AppHandle<R>, failure: RemoteFailure) -> Result<(), String> {
#[cfg(mobile)]
{
use tauri::Manager;
let session = app.state::<MediaSession<R>>();
session
.0
.run_mobile_plugin::<serde_json::Value>("remoteFailed", failure)
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = (app, failure);
Err("the media session is a phone feature".into())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("media-session")
.invoke_handler(tauri::generate_handler![update, clear])
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
+28
View File
@@ -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.
+40
View File
@@ -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.
+42
View File
@@ -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.
+9
View File
@@ -20,4 +20,13 @@ export PATH="$SDK/platform-tools:$PATH"
# module — ONLY when the CLI's variable names the project. Without it a new
# plugin (the media session) compiled fine and never reached the APK.
export TAURI_ANDROID_PROJECT_PATH="$(cygpath -w "$PWD/src-tauri/gen/android")"
# Gradle's AF_UNIX pipes die anywhere under the user profile on the work
# machine ("Unable to establish loopback connection" → "Invalid argument:
# connect"), and every gradlew invocation in build.sh, dev.sh and the prober
# went through it. Point its temp directory outside the profile when that
# directory exists; a machine without it (home) is untouched.
if [ -d /c/gradle-tmp ]; then
export TMP='C:\gradle-tmp'
export TEMP='C:\gradle-tmp'
fi
ADB="$SDK/platform-tools/adb.exe"
+616 -43
View File
@@ -18,6 +18,11 @@
//! Every failure path returns an empty-but-valid JSON array. A head unit that
//! is handed nothing draws an empty folder; a head unit that is left waiting
//! drops the app. Empty is always the better answer.
//!
//! Streamed playlist rows are the one place this reaches past the database:
//! a row whose remembered peer file (`stream_sources.rs`) is byte-complete in
//! the stream cache plays from here like a library track, because it IS a
//! file on this phone. Everything else streamed is handed to the app.
use jni::objects::{JObject, JString};
use jni::sys::jstring;
@@ -29,6 +34,11 @@ use std::path::{Path, PathBuf};
/// with a media id that happens to be a file path.
pub const ROOT: &str = "ss:root";
/// The root a host asks for with `EXTRA_RECENT`: Android's own "continue
/// where you left off" surfaces and Android Auto's resumption row. It holds
/// exactly one playable row, the resume point, or nothing.
pub const RECENT_ROOT: &str = "ss:recent-root";
/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists ·
/// Saved). Playlists is deliberately absent: the phone holds exactly one, and
/// an empty tab is a dead quarter of the only four root slots Auto gives us.
@@ -50,11 +60,10 @@ struct Node {
subtitle: String,
browsable: bool,
playable: bool,
/// Absolute path to a cover, when the row has one. Kotlin decides what a
/// Absolute path to a cover, or an https URL. Kotlin decides what a
/// path can become on the car's side; this layer only reports it.
art: Option<String>,
/// Absolute path to the audio file, for playable rows. Round fifty-one is
/// what does anything with it.
/// Absolute path to the audio file, for playable rows.
path: Option<String>,
}
@@ -77,7 +86,7 @@ impl Node {
/// called here because it takes a `tauri::AppHandle`). The database itself
/// always lives under `<library>/.soulstream/`, which is the rule that keeps
/// stored paths portable.
fn db_path(data_dir: &Path) -> PathBuf {
pub(crate) fn db_path(data_dir: &Path) -> PathBuf {
let configured = std::fs::read_to_string(data_dir.join("config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
@@ -264,24 +273,329 @@ fn saved(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
rows.collect()
}
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
// `plays` records what was played by NAME, not by track id, so getting
// back to a playable row means matching the pair again. Anything played
// that is not in the library any more simply drops out of the join, which
// is the right answer for a tab whose whole promise is that tapping a row
// works.
fn recent(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
// RECENTLY PLAYED ALBUMS, not tracks. A car's "Recent" is "put that back
// on", and what a driver means by that is the record — a list of single
// songs plays one and stops.
//
// `plays` records what was played by NAME rather than by track id, so
// getting back to an album means matching the pair again. Anything played
// that has since left the library drops out of the join, which is right
// for a row whose whole promise is that tapping it works.
let mut out: Vec<Node> = Vec::new();
// First of all: where playback was left. It is the row a driver wants
// most often, and it is the only one that knows a position.
out.extend(resume_row(data_dir));
// The count is the RECORD's tracks, not how many of them were played —
// "Daft Punk · 3 tracks" under a thirteen-track album was the played
// count leaking into the subtitle.
let mut stmt = conn.prepare(
"SELECT t.id, t.title, t.artist, t.path, a.art_path, MAX(p.started_at)
"SELECT a.id, a.title, a.artist, a.art_path,
(SELECT COUNT(*) FROM tracks x WHERE x.album_id = a.id), MAX(p.started_at)
FROM plays p
JOIN tracks t
ON t.title = p.title COLLATE NOCASE
AND t.artist = p.artist COLLATE NOCASE
JOIN albums a ON a.id = t.album_id
GROUP BY t.id
GROUP BY a.id
ORDER BY MAX(p.started_at) DESC
LIMIT 12",
)?;
if let Ok(rows) = stmt.query_map([], |r| {
let id: i64 = r.get(0)?;
let title: String = r.get(1)?;
let artist: String = r.get(2)?;
let art: Option<String> = r.get(3)?;
let n: i64 = r.get(4)?;
Ok(Node {
id: format!("ss:album:{id}"),
title,
subtitle: format!("{artist} · {}", plural(n, "track", "tracks")),
browsable: true,
playable: false,
art,
path: None,
})
}) {
out.extend(rows.flatten());
}
out.extend(playlists(conn, data_dir).unwrap_or_default());
// A library that has never been played is not an empty shelf. On a fresh
// install — or a phone whose history was wiped — "Recent" would otherwise
// be the first thing a driver sees and it would say "No items" over a full
// library. Newest first is a defensible reading of recent either way.
if out.is_empty() {
out.extend(albums(conn)?.into_iter().take(12));
}
Ok(out)
}
/// The one row the recent root holds: "continue", with the track that was
/// playing, where it was. Nothing when there is no resume point or none of
/// it can play from here.
fn resume_row(data_dir: &Path) -> Option<Node> {
let (tracks, idx, position, from) = crate::headless::resume_queue(data_dir)?;
let t = tracks.get(idx)?;
let mins = (position / 60.0).floor() as i64;
let secs = (position % 60.0).floor() as i64;
let at = if position > 0.5 { format!(" · {mins}:{secs:02}") } else { String::new() };
let source = if from.is_empty() { t.artist.clone() } else { from };
Some(Node {
id: crate::headless::RESUME_ID.to_owned(),
title: t.title.clone(),
subtitle: format!("Continue · {source}{at}"),
browsable: false,
playable: true,
art: t
.art
.clone()
.or_else(|| crate::cover_hunt::cached(data_dir, &t.artist, &t.title)),
path: t.here().map(|p| p.to_string_lossy().into_owned()),
})
}
/// The cover embedded in an audio file, as a file the car can be handed.
///
/// Thirty of GhettoTechie's forty rows have no catalog cover at all — the
/// import never matched them — and the car drew the app's icon over every
/// one (2026-09-18). Most of those files carry their real cover in the tag:
/// the desktop's `stream_embedded_art` extracts exactly this, keyed by the
/// bytes' hash. Here it is keyed by the AUDIO path, so "already extracted"
/// is one `stat` and a browse of forty rows reads forty tags once, ever.
/// Written beside the desktop's own extractions, under
/// `<library>/.soulstream/stream-art/`, which the FileProvider roots reach.
pub(crate) fn embedded_cover(data_dir: &Path, audio: &Path) -> Option<PathBuf> {
embedded_cover_from(data_dir, audio, audio, true)
}
/// [`embedded_cover`] for a file that is still arriving: keyed by the
/// cache path it will be (`key`), read from the partial it is now
/// (`read`). A miss is remembered only when `remember_miss` — the tag of
/// a partial file may simply not all be here yet.
pub(crate) fn embedded_cover_from(
data_dir: &Path,
key: &Path,
read: &Path,
remember_miss: bool,
) -> Option<PathBuf> {
let dir = db_path(data_dir).parent()?.join("stream-art").join("by-file");
let mut hash: u64 = 0xcbf29ce484222325;
for b in key.to_string_lossy().bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let out = dir.join(format!("{hash:016x}.img"));
if matches!(std::fs::metadata(&out), Ok(md) if md.len() > 0) {
return Some(out);
}
// A miss is remembered as an EMPTY file, so a tag with no picture is not
// re-read on every browse. (A file that later gains a cover is a case
// this library does not have.)
if std::fs::metadata(&out).is_ok() {
return None;
}
let bytes = match tagger::extract_art(read) {
Ok(Some(b)) if !b.is_empty() => b,
_ => {
if remember_miss {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(&out, b"");
}
return None;
}
};
std::fs::create_dir_all(&dir).ok()?;
let tmp = out.with_extension("img.tmp");
std::fs::write(&tmp, &bytes).ok()?;
std::fs::rename(&tmp, &out).ok()?;
Some(out)
}
/// A playlist row from this process: its local file when it has one; for a
/// streamed row, the byte-complete cache copy of its remembered peer file
/// when that is here, else `None` with the peer file itself so the queue
/// can fetch it again when it gets there (`headless.rs`).
fn row_file(
data_dir: &Path,
kind: &str,
path: Option<&str>,
track_id: Option<&str>,
artist: &str,
title: &str,
) -> (Option<PathBuf>, Option<crate::resume::StreamRef>) {
if kind == "local" {
return (path.map(PathBuf::from), None);
}
if kind != "stream" {
return (None, None);
}
let Some(src) = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title)) else {
return (None, None);
};
let file = crate::stream_sources::cached_file(data_dir, &src);
(
file,
Some(crate::resume::StreamRef {
username: src.username,
path: src.path,
size: src.size,
}),
)
}
/// Playlists, newest first, as browsable rows.
///
/// Counted out of `playlist_items` rather than `playlist_tracks`: it is the
/// richer table, and the one that knows whether an entry is a local file
/// (`kind`/`path`) or something that needs the resolver. A streamed entry
/// whose cache copy is whole counts as here.
fn playlists(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
let mut stmt = conn.prepare(
"SELECT p.id, p.name,
-- A streamed entry has no cover FILE, it has a remote URL.
-- Auto fetches an https artwork uri itself, so the row gets a
-- sleeve either way rather than a grey tile.
(SELECT COALESCE(i.art_path, i.art_url) FROM playlist_items i
WHERE i.playlist_id = p.id AND COALESCE(i.art_path, i.art_url) IS NOT NULL
ORDER BY i.position LIMIT 1)
FROM playlists p
ORDER BY p.created_at DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([ROW_CAP as i64], |r| Ok(track_node(r)?))?;
let heads: Vec<(i64, String, Option<String>)> = stmt
.query_map([ROW_CAP as i64], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?;
let mut out = Vec::with_capacity(heads.len());
for (id, name, art) in heads {
let (n, here) = playlist_counts(conn, data_dir, id)?;
// Say plainly when part of a playlist cannot play here. A row that
// silently plays four of its nine tracks is worse than one that says
// so before it is tapped.
let sub = if here < n {
format!("Playlist · {} of {} here", here, n)
} else {
format!("Playlist · {}", plural(n, "track", "tracks"))
};
out.push(Node {
id: format!("ss:playlist:{id}"),
title: name,
subtitle: sub,
browsable: true,
playable: false,
art,
path: None,
});
}
Ok(out)
}
/// (all rows, rows that play from this process) for one playlist.
fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<(i64, i64)> {
let mut stmt = conn.prepare(
"SELECT kind, path, track_id, artist, title FROM playlist_items WHERE playlist_id = ?1",
)?;
let mut n = 0i64;
let mut here = 0i64;
let rows = stmt.query_map([playlist_id], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
))
})?;
for row in rows.flatten() {
n += 1;
let (kind, path, track_id, artist, title) = row;
if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).0.is_some() {
here += 1;
}
}
Ok((n, here))
}
/// A playlist's entries, in order. The local ones and the streamed ones whose
/// cache copy is whole are playable; the rest need the resolver, which does
/// not exist in a process with no WebView, and are shown rather than hidden
/// so the list matches the phone's.
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
let mut stmt = conn.prepare(
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id, duration_secs
FROM playlist_items
WHERE playlist_id = ?1
ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
let id: i64 = r.get(0)?;
let title: String = r.get(1)?;
let artist: String = r.get(2)?;
let album: Option<String> = r.get(3)?;
let path: Option<String> = r.get(4)?;
let art: Option<String> = r.get(5)?;
let kind: String = r.get(6)?;
let track_id: Option<String> = r.get(7)?;
let duration = r.get::<_, Option<i64>>(8)?.map(|s| s as f64);
let (file, _) = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
// Every real row is PLAYABLE, whether or not its file is here: a
// non-playable, non-browsable item is drawn by the host as a bare
// tile in a list of rows (the first head-unit run showed four rows
// and thirty-six tiles). The row that will need the app says so in
// its subtitle, and a tap on it is the hand-off (warm) or the error
// card (cold) — never a silent nothing. Kotlin marks the ones whose
// file is here with the host's own "downloaded" indicator.
// A row whose bytes are not here yet is still a row that plays: the
// tap finds a copy on the network from this process now
// (headless_slsk.rs). It is not marked "downloaded"; that is the
// whole difference, and it thins out as rows are heard or kept.
let unresolved = kind == "unresolved";
let sub = if unresolved {
format!("{artist} · unresolved")
} else {
match &album {
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
_ => artist.clone(),
}
};
// No catalog cover: the one in the file's own tag, if it has one;
// then what the cover hunt has found; and a row still blank is
// handed to the hunt, which re-lists this playlist on the car
// when it has an answer (cover_hunt.rs).
let art = art
.or_else(|| {
file.as_ref()
.and_then(|p| embedded_cover(data_dir, p))
.map(|p| p.to_string_lossy().into_owned())
})
.or_else(|| {
if kind != "stream" {
return None;
}
let found = crate::cover_hunt::cached(data_dir, &artist, &title);
if found.is_none() {
crate::cover_hunt::request(crate::cover_hunt::Job {
data_dir: data_dir.to_path_buf(),
item_id: id,
artist: artist.clone(),
title: title.clone(),
duration,
parent: format!("ss:playlist:{playlist_id}"),
});
}
found
});
Ok(Node {
id: format!("ss:pitem:{id}"),
title,
subtitle: sub,
browsable: false,
playable: !unresolved,
art,
path: file.map(|p| p.to_string_lossy().into_owned()),
})
})?;
rows.collect()
}
@@ -305,19 +619,24 @@ fn track_node(r: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
/// The whole tree, dispatched on the parent id. Unknown ids answer with
/// nothing, which is what a head unit asking about a node we have since
/// stopped publishing should get.
fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
fn children(conn: &Connection, data_dir: &Path, parent: &str) -> rusqlite::Result<Vec<Node>> {
match parent {
ROOT => Ok(tabs()),
"ss:albums" => albums(conn),
"ss:artists" => artists(conn),
"ss:saved" => saved(conn),
"ss:recent" => recent(conn),
"ss:recent" => recent(conn, data_dir),
_ => {
if let Some(id) = parent.strip_prefix("ss:album:") {
match id.parse::<i64>() {
Ok(n) => album_tracks(conn, n),
Err(_) => Ok(Vec::new()),
}
} else if let Some(id) = parent.strip_prefix("ss:playlist:") {
match id.parse::<i64>() {
Ok(n) => playlist_items(conn, data_dir, n),
Err(_) => Ok(Vec::new()),
}
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
// The rest of the id is the artist's name verbatim, however
// many colons it happens to contain.
@@ -330,7 +649,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
}
/// Turn a media id from the browse tree into something playable: the files, in
/// order, and which one the tap meant.
/// order, which one the tap meant, and the name of what it came from.
///
/// A tapped TRACK brings its whole album with it at the right index, because
/// "the album keeps going" is what a record player does and what a driver
@@ -343,9 +662,10 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
pub(crate) fn resolve_playable(
data_dir: &str,
media_id: &str,
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
) -> Option<(Vec<crate::headless::PlayTrack>, usize, String)> {
use crate::headless::PlayTrack;
let db = db_path(Path::new(data_dir));
let dir = Path::new(data_dir);
let db = db_path(dir);
let conn = match open_read_only(&db) {
Ok(c) => c,
Err(e) => {
@@ -353,7 +673,7 @@ pub(crate) fn resolve_playable(
return None;
}
};
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
let album_tracks = |album_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
let mut stmt = conn.prepare(
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
FROM tracks t JOIN albums a ON a.id = t.album_id
@@ -361,24 +681,130 @@ pub(crate) fn resolve_playable(
ORDER BY t.disc_no, t.track_no, t.title",
)?;
let rows = stmt.query_map([album_id], |r| {
let id = r.get::<_, i64>(0)?;
Ok((
r.get::<_, i64>(0)?,
id,
PlayTrack {
path: PathBuf::from(r.get::<_, String>(1)?),
id: Some(id.to_string()),
path: Some(PathBuf::from(r.get::<_, String>(1)?)),
title: r.get(2)?,
artist: r.get(3)?,
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
album: r.get(5)?,
art: r.get(6)?,
stream: None,
},
))
})?;
rows.collect()
let rows: Vec<(i64, PlayTrack)> = rows.collect::<rusqlite::Result<_>>()?;
let from = rows.first().map(|(_, t)| t.album.clone()).unwrap_or_default();
Ok((rows, from))
};
if let Some(id) = media_id.strip_prefix("ss:album:") {
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
}
// A playlist plays EVERY row, in order, exactly as the page would: the
// local files and whole cache copies from their files, and the rows
// whose bytes are not here found on the network when the queue reaches
// them — the remembered peer file first, else a search — with the next
// few fetched ahead (`headless.rs`). Only a row the page itself could
// not play (`unresolved`, no title) is left out.
let playlist_rows = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
let name: String = conn
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
.unwrap_or_default();
let mut stmt = conn.prepare(
"SELECT id, title, artist, album, duration_secs, path, COALESCE(art_path, art_url), kind, track_id
FROM playlist_items
WHERE playlist_id = ?1
ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
let id: i64 = r.get(0)?;
let title: String = r.get(1)?;
let artist: String = r.get(2)?;
let album: Option<String> = r.get(3)?;
let duration = r.get::<_, Option<i64>>(4)?.map(|s| s as f64);
let path: Option<String> = r.get(5)?;
let art: Option<String> = r.get(6)?;
let kind: String = r.get(7)?;
let track_id: Option<String> = r.get(8)?;
Ok((id, title, artist, album, duration, path, art, kind, track_id))
})?;
let mut out = Vec::new();
for row in rows.flatten() {
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
if title.trim().is_empty() || !(kind == "local" || kind == "stream") {
continue;
}
let (file, stream) =
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
if kind == "local" && file.is_none() {
continue;
}
// No catalog cover: the file's own, so the playback screen and
// the colour the car washes it with are the record's, not the
// app icon's; else what the cover hunt has found, and a row
// still blank is handed to it.
let art = art
.or_else(|| {
file.as_ref()
.and_then(|f| embedded_cover(dir, f))
.map(|p| p.to_string_lossy().into_owned())
})
.or_else(|| {
if kind != "stream" {
return None;
}
let found = crate::cover_hunt::cached(dir, &artist, &title);
if found.is_none() {
crate::cover_hunt::request(crate::cover_hunt::Job {
data_dir: dir.to_path_buf(),
item_id: id,
artist: artist.clone(),
title: title.clone(),
duration,
parent: format!("ss:playlist:{playlist_id}"),
});
}
found
});
out.push((
id,
PlayTrack {
id: Some(format!("pl{id}")),
path: file,
title,
artist,
album: album.unwrap_or_default(),
duration,
art,
stream,
},
));
}
Ok((out, name))
};
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
let (rows, from) = playlist_rows(id.parse::<i64>().ok()?).ok()?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
}
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
let item_id = id.parse::<i64>().ok()?;
let playlist_id: i64 = conn
.query_row(
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
[item_id],
|r| r.get(0),
)
.ok()?;
let (rows, from) = playlist_rows(playlist_id).ok()?;
// The tapped row is where the queue starts — the one under the
// driver's finger, never a neighbour of it.
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
}
if let Some(id) = media_id.strip_prefix("ss:track:") {
let track_id = id.parse::<i64>().ok()?;
@@ -387,9 +813,9 @@ pub(crate) fn resolve_playable(
r.get(0)
})
.ok()?;
let rows = album_tracks(album_id).ok()?;
let (rows, from) = album_tracks(album_id).ok()?;
let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0);
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
}
None
}
@@ -478,9 +904,16 @@ fn browse_json(data_dir: &str, parent: &str) -> String {
if parent == ROOT {
return rows(&tabs());
}
let db = db_path(Path::new(data_dir));
let dir = Path::new(data_dir);
// Nor does the recent root: it is one row out of resume.json, and a
// host asking for it is asking "can you continue?", not "show me the
// library".
if parent == RECENT_ROOT {
return rows(&resume_row(dir).into_iter().collect::<Vec<_>>());
}
let db = db_path(dir);
let nodes = match open_read_only(&db) {
Ok(conn) => children(&conn, parent).unwrap_or_else(|e| {
Ok(conn) => children(&conn, dir, parent).unwrap_or_else(|e| {
warn(&format!("auto browse {parent}: {e}"));
Vec::new()
}),
@@ -502,7 +935,7 @@ fn rows(nodes: &[Node]) -> String {
/// A cold service process has never run Tauri, so nothing has installed an
/// `android_logger` and `log::warn!` goes nowhere — which is why the first
/// failure here was silent. Write to the platform log directly.
fn warn(msg: &str) {
pub(crate) fn warn(msg: &str) {
let tag = std::ffi::CString::new("SoulStream").unwrap_or_default();
if let Ok(m) = std::ffi::CString::new(msg) {
// SAFETY: both pointers are valid NUL-terminated strings for the call.
@@ -527,16 +960,21 @@ extern "C" {
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
mut env: JNIEnv,
_this: JObject,
this: JObject,
data_dir: JString,
parent: JString,
) -> jstring {
// The class, for the pump's calls back into Kotlin — see headless.rs.
crate::headless::remember_auto_native(&mut env, &this);
// Read both strings before anything else borrows `env`; a closure over it
// would have to be `mut` and then collide with the hand-back below.
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let par: Option<String> = env.get_string(&parent).ok().map(Into::into);
let out = match (dir, par) {
(Some(d), Some(p)) => browse_json(&d, &p),
(Some(d), Some(p)) => {
crate::headless::set_data_dir(Path::new(&d));
browse_json(&d, &p)
}
_ => "[]".to_owned(),
};
// Even the hand-back has to not panic across the boundary: an empty array
@@ -547,25 +985,29 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
/// a process that may have no Activity and no WebView.
/// `AutoNative.play(dataDir, mediaId, startMs)` — start playback from a
/// browse row, in a process that may have no Activity and no WebView.
/// `startMs` below zero means "where it was left" (the resume row) or the
/// top (everything else).
///
/// Returns how many tracks were queued, so Kotlin can answer the car with an
/// error state instead of silence when a row resolves to nothing. Catalog and
/// Soulseek rows are not in the tree yet and will resolve to nothing by
/// construction, which is the correct answer for them: they need the resolver,
/// which needs the WebView.
/// Soulseek rows with no cache copy resolve to nothing by construction, which
/// is the correct answer for them: they need the resolver, which needs the
/// WebView.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
mut env: JNIEnv,
_this: JObject,
this: JObject,
data_dir: JString,
media_id: JString,
start_ms: jni::sys::jlong,
) -> jstring {
crate::headless::remember_auto_native(&mut env, &this);
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
let out = match (dir, id) {
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m),
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m, start_ms),
_ => "{\"queued\":0}".to_owned(),
};
env.new_string(out)
@@ -574,6 +1016,30 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.resumeInfo(dataDir)` — what would resume, and where, as one
/// JSON object; `{"ok":false}` when nothing can. Never starts anything:
/// Android's own resumption surfaces ask this to DRAW a card long before
/// anyone presses play on it.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_resumeInfo(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let out = match dir {
Some(d) => {
crate::headless::set_data_dir(Path::new(&d));
crate::headless::resume_info_json(Path::new(&d))
}
None => "{\"ok\":false}".to_owned(),
};
env.new_string(out)
.or_else(|_| env.new_string("{\"ok\":false}"))
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
/// same JSON shape as a browse.
#[no_mangle]
@@ -595,6 +1061,107 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
.unwrap_or(std::ptr::null_mut())
}
/// What a media id IS, for the page.
///
/// While the app is up the page owns the queue — shuffle, repeat, failover,
/// the resolver, the session it restores — so a car's tap is handed to it
/// whole rather than started on the engine underneath it, which would leave
/// the bar naming one track while another played. The page cannot parse
/// our ids and should not: this turns one into the thing the page already
/// knows how to play, and the id stays a thing the Rust side minted.
pub(crate) fn describe_json(data_dir: &str, media_id: &str) -> String {
if media_id == crate::headless::RESUME_ID {
return serde_json::json!({ "kind": "resume" }).to_string();
}
if let Some(id) = media_id.strip_prefix("ss:album:") {
if let Ok(n) = id.parse::<i64>() {
return serde_json::json!({ "kind": "album", "albumId": n }).to_string();
}
}
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
if let Ok(n) = id.parse::<i64>() {
return serde_json::json!({ "kind": "playlist", "playlistId": n, "itemId": 0 }).to_string();
}
}
let conn = match open_read_only(&db_path(Path::new(data_dir))) {
Ok(c) => c,
Err(_) => return serde_json::json!({ "kind": "unknown" }).to_string(),
};
if let Some(id) = media_id.strip_prefix("ss:track:") {
if let Ok(track) = id.parse::<i64>() {
if let Ok(album) = conn.query_row(
"SELECT album_id FROM tracks WHERE id = ?1",
[track],
|r| r.get::<_, i64>(0),
) {
return serde_json::json!({ "kind": "track", "albumId": album, "trackId": track })
.to_string();
}
}
}
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
if let Ok(item) = id.parse::<i64>() {
if let Ok(pl) = conn.query_row(
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
[item],
|r| r.get::<_, i64>(0),
) {
return serde_json::json!({ "kind": "playlist", "playlistId": pl, "itemId": item })
.to_string();
}
}
}
serde_json::json!({ "kind": "unknown" }).to_string()
}
/// `AutoNative.describe(dataDir, mediaId)` — the page's view of a browse
/// row, for the warm hand-off. See [`describe_json`].
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_describe(
mut env: JNIEnv,
_this: JObject,
data_dir: JString,
media_id: JString,
) -> jstring {
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
let out = match (dir, id) {
(Some(d), Some(m)) => describe_json(&d, &m),
_ => "{\"kind\":\"unknown\"}".to_owned(),
};
env.new_string(out)
.or_else(|_| env.new_string("{\"kind\":\"unknown\"}"))
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
/// What a row would need the APP for, when it cannot play from here.
///
/// A playlist entry with `kind='stream'` and no whole cache copy has no file
/// on this phone — only a catalog id — and turning that into audio is the
/// resolver's job, which is hundreds of lines of shared JS that exist only
/// while a WebView does. So this does not try. It reports WHICH playlist and
/// entry were meant, and the warm path hands that to the app, which already
/// knows how to play it.
pub(crate) fn remote_ref(data_dir: &str, media_id: &str) -> Option<(i64, i64)> {
let conn = open_read_only(&db_path(Path::new(data_dir))).ok()?;
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
return Some((id.parse::<i64>().ok()?, 0));
}
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
let item = id.parse::<i64>().ok()?;
let pl: i64 = conn
.query_row(
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
[item],
|r| r.get(0),
)
.ok()?;
return Some((pl, item));
}
None
}
/// `AutoNative.shuffle()` — reorder what is still ahead in the headless
/// queue. Returns how many tracks were reordered, so the car can be told
/// nothing happened rather than showing a button that silently does nothing.
@@ -612,13 +1179,15 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle(
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
mut env: JNIEnv,
_this: JObject,
this: JObject,
action: JString,
) {
crate::headless::remember_auto_native(&mut env, &this);
let Some(a) = env.get_string(&action).ok().map(String::from) else {
return;
};
let audio = crate::headless::audio(None);
warn(&format!("auto transport {a}"));
match a.as_str() {
"pause" => audio.player.pause(),
"resume" => audio.player.resume(),
@@ -652,7 +1221,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jlong {
(crate::headless::audio(None).progress().0 * 1000.0) as jni::sys::jlong
match crate::headless::audio_if_up() {
Some(a) => (a.progress().0 * 1000.0) as jni::sys::jlong,
None => 0,
}
}
/// `AutoNative.sounding()` — whether the engine is actually playing, as
@@ -662,16 +1234,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_sounding(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jboolean {
u8::from(crate::headless::audio(None).progress().1)
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.progress().1))
}
/// `AutoNative.driving()` — true when the headless queue is what is playing,
/// so Kotlin can send a transport command to Rust instead of to a WebView
/// that may not exist.
/// that may not exist. Asked on every card rebuild, so it must never CREATE
/// the engine: a process with no engine is not driving.
#[no_mangle]
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
_env: JNIEnv,
_this: JObject,
) -> jni::sys::jboolean {
u8::from(crate::headless::audio(None).driving())
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.driving()))
}
+355 -21
View File
@@ -15,13 +15,21 @@ use crate::state::AppState;
pub struct AppStatus {
pub version: &'static str,
pub connected_as: Option<String>,
/// A dropped session is being dialled back (state.rs `Reconnect`).
pub redialing: bool,
}
#[tauri::command]
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
let redialing = state
.reconnect
.lock()
.map(|r| r.dialing)
.unwrap_or(false);
Ok(AppStatus {
version: env!("CARGO_PKG_VERSION"),
connected_as: state.connected_as.lock().await.clone(),
redialing,
})
}
@@ -456,6 +464,32 @@ pub async fn connect_and_login(
password: String,
remember: bool,
) -> Result<String, String> {
// An explicit login is a new generation: whatever redial loop the last
// session's drop may have started stands down, and the old pump's own
// Disconnected (from the shutdown inside login_inner) schedules nothing.
let generation = {
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.generation += 1;
r.wanted = true;
r.attempt = 0;
r.dialing = false;
r.generation
};
login_inner(app, username, password, remember, generation).await
}
/// The login itself, shared by the user's own tap and the automatic redial
/// (`schedule_reconnect`). `generation` is the session this login belongs
/// to: the event pump it spawns carries it, and a drop reported by a pump
/// of an older generation redials nothing and says nothing.
pub(crate) async fn login_inner(
app: tauri::AppHandle,
username: String,
password: String,
remember: bool,
generation: u64,
) -> Result<String, String> {
let state = app.state::<AppState>();
// Drop any previous session first.
if let Some(old) = state.client.lock().await.take() {
old.shutdown();
@@ -481,27 +515,62 @@ pub async fn connect_and_login(
cfg.shared_folders = shares;
cfg.description = "Shared with SoulStream".into();
// The previous session's listener releases its port a moment after
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
let (client, mut events) = {
let mut attempt = 0;
loop {
match Client::connect(cfg.clone()).await {
Ok(pair) => break pair,
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
// A car may already have signed this process in (headless_slsk.rs) to
// stream a row before the page existed. Take that session over rather
// than logging in again — two logins from one address in quick
// succession is what the server throttles — and read its events from
// here on; the fetch in flight keeps going on the same client.
#[cfg(target_os = "android")]
let adopted = crate::headless_slsk::adopt().map(|(client, user)| {
log::info!("soulseek: adopting the car's session as {user}");
(client, crate::headless_slsk::subscribe())
});
#[cfg(not(target_os = "android"))]
let adopted: Option<(Client, tokio::sync::mpsc::UnboundedReceiver<Event>)> = None;
let (client, mut events, greeting) = match adopted {
Some((client, events)) => {
// The car's queue may be fetching the rows ahead of it. Those
// downloads are the page's preloads from here on: their
// completion reaches the page as `soulseek:preload`, the same
// way its own next-track fetches do, and the page's queue —
// which took the car's over (`restoreSession`'s handover) —
// appends them for the gapless boundary as they land.
#[cfg(target_os = "android")]
if let (Some(audio), Some(ps)) =
(crate::headless::audio_if_up(), app.try_state::<PlayerState>())
{
let mut preloads = ps.preloads.lock().unwrap();
for (id, cache) in audio.fetches_in_flight() {
preloads.entry(id).or_insert(cache);
}
Err(e) => return Err(format!("could not reach server: {e}")),
}
(client, events, "adopted".to_string())
}
None => {
// The previous session's listener releases its port a moment after
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
let (client, events) = {
let mut attempt = 0;
loop {
match Client::connect(cfg.clone()).await {
Ok(pair) => break pair,
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
Err(e) => return Err(format!("could not reach server: {e}")),
}
}
};
let greeting = client
.login(&username, &password)
.await
.map_err(|e| e.to_string())?;
(client, events, greeting)
}
};
let greeting = client
.login(&username, &password)
.await
.map_err(|e| e.to_string())?;
if remember {
let mut save = stored.clone();
save.username = username.clone();
@@ -533,6 +602,12 @@ pub async fn connect_and_login(
*state.client.lock().await = Some(client);
*state.connected_as.lock().await = Some(username.clone());
{
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.connected_at = crate::resume::now_secs();
r.dialing = false;
r.wanted = true;
}
// Forward client events to the webview for the life of this session.
let event_app = app.clone();
@@ -855,10 +930,45 @@ pub async fn connect_and_login(
}
}
Event::Disconnected { reason } => {
let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason });
// A pump outlives the session it served for a moment:
// login_inner shuts the old client down and its reader
// reports the drop AFTER the new session has logged in.
// Until 2026-09-18 that stale drop stamped the fresh
// session offline and cleared its username. Only the
// pump of the current generation may say "disconnected".
let (current, wanted) = event_app
.try_state::<AppState>()
.and_then(|st| st.reconnect.lock().ok().map(|r| (r.generation, r.wanted)))
.unwrap_or((generation, true));
if current != generation {
// Two ways to be stale: replaced by a newer login
// (say nothing — the page already heard "connected"
// from it), or ended by the user's own Disconnect,
// which the page still deserves to hear.
if !wanted {
let _ = event_app.emit(
"soulseek:state",
UiEvent::Disconnected {
reason: reason.clone(),
},
);
}
log::info!("soulseek: an older session's pump ended ({reason})");
break;
}
log::warn!("soulseek: disconnected ({reason})");
let _ = event_app.emit(
"soulseek:state",
UiEvent::Disconnected {
reason: reason.clone(),
},
);
if let Some(st) = event_app.try_state::<AppState>() {
*st.connected_as.lock().await = None;
}
// The drop belongs to THIS session: dial it back unless
// the user ended it on purpose.
schedule_reconnect(event_app.clone(), generation);
break;
}
Event::ExcludedSearchPhrases(p) => {
@@ -1033,6 +1143,209 @@ fn now_ms() -> u64 {
.unwrap_or(0)
}
/// The wait before redial number `attempt` (1-based), in seconds.
///
/// 5 · 15 · 45 · 120 · 300 · 300 …, each with up to a fifth of jitter so
/// two instances on one address never dial in step. The server drops the
/// handshake of anyone who logs in eight to ten times in quick succession
/// (paid for on 2026-08-03), and eight of these span a quarter of an hour.
fn redial_wait_secs(attempt: u32) -> u64 {
let base: u64 = match attempt {
0 | 1 => 5,
2 => 15,
3 => 45,
4 => 120,
_ => 300,
};
let jitter = (now_ms() % 1000) * base / 5000; // 0 ..= base/5
base + jitter
}
/// Dial the dropped session back, on a schedule the server tolerates.
///
/// One loop per generation: it stands down the moment the user connects or
/// disconnects by hand (either bumps the generation), the moment a login
/// lands (its own or anyone's), and when there are no stored credentials to
/// dial with. Every attempt is announced to the page as
/// `soulseek:state { kind: "reconnecting", attempt, in_secs }`, so the
/// network row can say "reconnecting…" instead of "offline".
///
/// The attempt counter is reset by a drop that FOLLOWED a stable session
/// (two minutes or more), and kept by a drop that followed a redial which
/// lasted seconds — so a link that flaps keeps backing off rather than
/// hammering.
pub(crate) fn schedule_reconnect(app: tauri::AppHandle, generation: u64) {
{
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
if !r.wanted || r.generation != generation || r.dialing {
return;
}
let now = crate::resume::now_secs();
if r.connected_at > 0 && now.saturating_sub(r.connected_at) >= 120 {
r.attempt = 0;
}
r.dialing = true;
}
tauri::async_runtime::spawn(async move {
loop {
let (attempt, wait) = {
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
if !r.wanted || r.generation != generation {
r.dialing = false;
return;
}
r.attempt += 1;
(r.attempt, redial_wait_secs(r.attempt))
};
log::info!("soulseek: redial {attempt} in {wait}s");
let _ = app.emit(
"soulseek:state",
serde_json::json!({ "kind": "reconnecting", "attempt": attempt, "in_secs": wait }),
);
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
{
let state = app.state::<AppState>();
let stale = {
let r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
!r.wanted || r.generation != generation
};
if stale || state.connected_as.lock().await.is_some() {
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.dialing = false;
return;
}
}
let cfg = config::load(&app);
let Some(password) = config::load_password(&cfg.username).filter(|_| !cfg.username.is_empty())
else {
// Nothing to dial with: the user signed in without
// remembering. Say so once and stop wanting.
log::warn!("soulseek: cannot redial, no stored credentials");
let state = app.state::<AppState>();
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.wanted = false;
r.dialing = false;
return;
};
match login_inner(app.clone(), cfg.username.clone(), password, false, generation).await {
Ok(_) => {
log::info!("soulseek: redial {attempt} succeeded");
return;
}
Err(e) => {
log::warn!("soulseek: redial {attempt} failed: {e}");
// login_inner cleared connected_as; the loop goes round.
}
}
}
});
}
/// DEBUG ONLY: sever the live server connection the way a network drop
/// would, without touching the user's intent — so the redial can be watched
/// from the page. A release build refuses.
#[tauri::command]
pub async fn debug_drop_connection(state: State<'_, AppState>) -> Result<(), String> {
#[cfg(debug_assertions)]
{
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
client.shutdown();
Ok(())
}
#[cfg(not(debug_assertions))]
{
let _ = state;
Err("debug builds only".into())
}
}
/// The page naming its queue for the resume point (`resume.rs`): the window
/// around the current track, the index, the position, and what the queue
/// was built from. A no-op off Android — the desktop restores from its own
/// session and nothing there starts the app without a page.
#[tauri::command]
pub fn playback_note_queue(
app: tauri::AppHandle,
items: Vec<crate::resume::ResumeItem>,
idx: usize,
position: f64,
from: Option<String>,
) -> Result<(), String> {
#[cfg(target_os = "android")]
{
let rp = crate::resume::ResumePoint {
items,
idx,
position: position.max(0.0),
from: from.unwrap_or_default(),
updated_at: crate::resume::now_secs(),
};
if let Ok(dir) = crate::paths::app_data(&app) {
crate::headless::set_data_dir(&dir);
}
// An empty queue is a no-op, never a clear: a page with nothing on
// its bar is the state of every boot before the session is restored,
// and a stale resume point costs a wrong "Continue" where a wiped one
// costs the feature (2026-09-18).
if rp.items.is_empty() {
return Ok(());
}
match crate::headless::audio_if_up() {
Some(audio) => audio.note_queue(rp),
None => {
if let Ok(dir) = crate::paths::app_data(&app) {
crate::resume::save(&dir, &rp);
}
}
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, items, idx, position, from);
Ok(())
}
}
/// The playlists kept offline (config.rs `offline_playlists`).
#[tauri::command]
pub fn playlist_offline_list(app: tauri::AppHandle) -> Vec<i64> {
config::load(&app).offline_playlists
}
/// Keep a playlist offline, or stop. The fetching itself is the page's
/// (src/mobile/offline.svelte.js) — this only records the wish.
#[tauri::command]
pub fn playlist_offline_set(app: tauri::AppHandle, id: i64, keep: bool) -> Vec<i64> {
let mut cfg = config::load(&app);
cfg.offline_playlists.retain(|p| *p != id);
if keep {
cfg.offline_playlists.push(id);
}
config::save(&app, &cfg);
cfg.offline_playlists
}
/// Where playback was left (`resume.rs`), for the page: a car's PLAY into a
/// warm app with nothing on the bar is handed to the page as "resume", and
/// the page builds its queue from this. `None` when there is nothing, and
/// always `None` off Android.
#[tauri::command]
pub fn playback_resume_point(app: tauri::AppHandle) -> Option<crate::resume::ResumePoint> {
#[cfg(target_os = "android")]
{
crate::paths::app_data(&app).ok().and_then(|d| crate::resume::load(&d))
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
None
}
}
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
serde_json::json!({
"username": u.username,
@@ -4073,6 +4386,14 @@ pub async fn catalog_album_info(
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
// The user's own choice: the redial stands down and the pump's coming
// Disconnected is an older generation's, which schedules nothing.
{
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
r.wanted = false;
r.generation += 1;
r.dialing = false;
}
if let Some(client) = state.client.lock().await.take() {
client.shutdown();
}
@@ -5317,12 +5638,25 @@ pub async fn waveform(
/// under the words "Nothing playing". This is the boot-time answer, the same
/// shape of recovery `app_status` already provides for the Soulseek connection.
#[tauri::command]
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
players
pub fn player_state(players: State<'_, PlayerState>) -> PlayerStateReply {
let snapshot = players
.last_transport
.lock()
.map(|snapshot| snapshot.clone())
.unwrap_or_default()
.unwrap_or_default();
// Taken, not read: the page that asks first owns the handover, and a
// reload later must not restore a car queue that is long gone.
let handover = players.handover.lock().ok().and_then(|mut h| h.take());
PlayerStateReply { snapshot, handover }
}
/// The transport, plus — once, on Android, after a car started the engine —
/// the queue it was driving (`playback.rs` `handover`).
#[derive(Serialize)]
pub struct PlayerStateReply {
#[serde(flatten)]
pub snapshot: crate::playback::PlaybackSnapshot,
pub handover: Option<crate::resume::ResumePoint>,
}
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
@@ -5502,7 +5836,7 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
type DwmSet =
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
unsafe {
let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr());
let dwm = LoadLibraryA(b"dwmapi.dll\0".as_ptr());
if dwm.is_null() {
return;
}
+14
View File
@@ -144,6 +144,12 @@ pub struct AppConfig {
/// base surfaces thinned in CSS so the desktop glows through. None → false.
#[serde(default)]
pub glass_mode: Option<bool>,
/// Playlists the phone keeps offline: every streamed row fetched into the
/// stream cache in the background and pinned there, so the whole list
/// plays with the app shut — in a car, in a pocket. Ids from
/// `playlists.id`; a deleted playlist's id is harmless here.
#[serde(default)]
pub offline_playlists: Vec<i64>,
/// The Soulseek hub's buddy list — usernames watched on every login so
/// their status and stats stream in while connected.
#[serde(default)]
@@ -529,6 +535,14 @@ pub fn remember_secrets_dir(app: &tauri::AppHandle) {
}
}
/// The same, from a process that has no `AppHandle` — the media service a
/// car starts cold, which needs the stored password to sign in to Soulseek
/// for a row the phone does not hold yet (headless_slsk.rs).
#[cfg(target_os = "android")]
pub fn remember_secrets_dir_path(dir: &std::path::Path) {
let _ = SECRETS_DIR.set(dir.to_path_buf());
}
#[cfg(target_os = "android")]
fn secrets_path() -> Option<PathBuf> {
let dir = SECRETS_DIR.get()?;
+771
View File
@@ -0,0 +1,771 @@
//! Covers for rows the catalog was never asked about — in the process that
//! has no page to ask for them.
//!
//! The car draws the app icon over a row with no cover, and a browse of a
//! pasted-tracklist playlist is mostly such rows. The page fills them in
//! the background when the app is up (`src/mobile/coverart.svelte.js`); a
//! car that starts the app COLD has no page, so this does the same from
//! Rust: the catalog's track search (the metadata crate, the same Deezer
//! field queries the page's ladder asks), and a match only when the artist
//! agrees, the title agrees once its version wording is set aside, the
//! versions are compatible, and a known duration confirms rather than
//! contradicts. `src/lib/catalogMatch.js` is the reference for every rule
//! here and this is its twin for a process with no JavaScript — keep them
//! in step. A loose lookup once put one record's cover on seven unrelated
//! songs, so no match means no art; a blank tile is the honest answer.
//!
//! What is found is written on the ROW (`playlist_items.art_url`, and the
//! track id beside it) — the one place the page, the car and the resume
//! point all read. The library is WAL and this is a second connection
//! from a second process, which SQLite was built for; the page's own
//! backfill writes the same column through `playlist_relink_item`, and
//! both only ever fill a NULL. What was asked is remembered beside the
//! extracted covers (`<library>/.soulstream/stream-art/catalog.json`), a
//! miss for a day, so a cold browse does not re-ask the catalog thirty
//! times about rows it already said no to.
//!
//! The browse answers at once with what is known; the rest is looked up
//! on one thread, one row at a time behind the metadata client's own
//! Deezer pacing, and each row found re-lists its parent on the car
//! (`AutoNative.headlessCovers` → `notifyChildrenChanged`).
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// A row that wants a cover.
#[derive(Clone)]
pub(crate) struct Job {
pub data_dir: PathBuf,
pub item_id: i64,
pub artist: String,
pub title: String,
pub duration: Option<f64>,
/// The browse parent to re-list on the car once this row has its cover.
pub parent: String,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct Known {
/// `None` is a real answer: the catalog was asked and nothing cleared
/// the bar.
url: Option<String>,
#[serde(default)]
track_id: Option<u64>,
/// Unix seconds; a miss expires after [`MISS_TTL`].
at: u64,
/// The matcher that answered. A miss from an older one is no answer:
/// the shelf rung arrived after a "no" for Nirvana's studio cut had
/// been remembered for the day, and the new rung never got to run
/// (2026-09-18). Bump [`HUNT_VERSION`] whenever a rung is added.
#[serde(default)]
v: u32,
}
const MISS_TTL: u64 = 24 * 3600;
const HUNT_VERSION: u32 = 2;
static CACHE: Mutex<Option<(PathBuf, HashMap<String, Known>)>> = Mutex::new(None);
static QUEUE: Mutex<VecDeque<Job>> = Mutex::new(VecDeque::new());
static WORKING: Mutex<bool> = Mutex::new(false);
/// Behind an `Arc` because the shelf walk takes `self: &Arc<Self>`.
static CLIENT: OnceLock<std::sync::Arc<metadata::MetadataClient>> = OnceLock::new();
/// When the catalog last returned anything at all. A row every query
/// comes back empty for is a MISS while the catalog is demonstrably
/// answering (a single-word underground title, 2026-09-18: four empty
/// lists), and "unreachable" only when nothing has answered for a while.
static LAST_ANSWER: Mutex<Option<Instant>> = Mutex::new(None);
const REACHABLE_FOR: Duration = Duration::from_secs(120);
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn cache_file(data_dir: &Path) -> Option<PathBuf> {
Some(
crate::auto_browse::db_path(data_dir)
.parent()?
.join("stream-art")
.join("catalog.json"),
)
}
/// The remembered answers, read once per process.
fn with_cache<R>(data_dir: &Path, f: impl FnOnce(&mut HashMap<String, Known>) -> R) -> R {
let mut guard = lock(&CACHE);
let Some(file) = cache_file(data_dir) else {
let mut empty = HashMap::new();
return f(&mut empty);
};
if guard.as_ref().is_none_or(|(p, _)| *p != file) {
let map: HashMap<String, Known> = std::fs::read_to_string(&file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
*guard = Some((file, map));
}
let (_, map) = guard.as_mut().expect("cache just loaded");
f(map)
}
fn save(data_dir: &Path) {
let guard = lock(&CACHE);
let Some((file, map)) = guard.as_ref() else {
return;
};
if cache_file(data_dir).as_ref() != Some(file) {
return;
}
if let Some(parent) = file.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(s) = serde_json::to_string(map) {
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
}
pub(crate) fn key_of(artist: &str, title: &str) -> String {
format!("{}|{}", name_norm(artist), name_norm(title))
}
/// A cover already found for this track, if any. Never asks anything.
pub(crate) fn cached(data_dir: &Path, artist: &str, title: &str) -> Option<String> {
if artist.trim().is_empty() || title.trim().is_empty() {
return None;
}
let key = key_of(artist, title);
with_cache(data_dir, |map| map.get(&key).and_then(|k| k.url.clone()))
}
/// Answered already — a cover, or a miss that has not expired.
fn known(data_dir: &Path, artist: &str, title: &str) -> bool {
let key = key_of(artist, title);
let now = now_secs();
with_cache(data_dir, |map| {
map.get(&key).is_some_and(|k| {
k.url.is_some() || (k.v >= HUNT_VERSION && now.saturating_sub(k.at) < MISS_TTL)
})
})
}
/// Ask for this row's cover in the background. Nothing happens for a row
/// already answered or already waiting.
pub(crate) fn request(job: Job) {
if job.artist.trim().is_empty() || job.title.trim().is_empty() {
return;
}
if known(&job.data_dir, &job.artist, &job.title) {
return;
}
{
let mut q = lock(&QUEUE);
if q.iter().any(|j| j.item_id == job.item_id) {
return;
}
q.push_back(job);
}
let mut working = lock(&WORKING);
if *working {
return;
}
*working = true;
drop(working);
let _ = std::thread::Builder::new()
.name("cover-hunt".into())
.spawn(work);
}
fn work() {
// parents with a row newly covered, re-listed on the car at most every
// couple of seconds, and once more when the queue runs dry
let mut pending: HashSet<String> = HashSet::new();
let mut last_notice = Instant::now() - Duration::from_secs(10);
loop {
let job = lock(&QUEUE).pop_front();
let Some(job) = job else {
break;
};
// the same track may sit in several playlists; one answer serves all
let found = lookup(&job.data_dir, &job.artist, &job.title, job.duration, false);
if let Some((url, track_id)) = found {
persist(&job.data_dir, job.item_id, &job.artist, &job.title, &url, track_id);
pending.insert(job.parent.clone());
}
if !pending.is_empty() && last_notice.elapsed() >= Duration::from_secs(2) {
for p in pending.drain() {
notify(&p);
}
last_notice = Instant::now();
}
}
for p in pending.drain() {
notify(&p);
}
*lock(&WORKING) = false;
// a request that landed between the last pop and the flag
if !lock(&QUEUE).is_empty() {
let mut working = lock(&WORKING);
if !*working {
*working = true;
drop(working);
let _ = std::thread::Builder::new().name("cover-hunt".into()).spawn(work);
}
}
}
fn notify(parent: &str) {
let json = serde_json::json!({ "parent": parent }).to_string();
crate::headless::call_auto_native("headlessCovers", &json);
}
/// How long the shelf walk may take for the track that is PLAYING. It
/// opens up to thirty tracklists behind the Deezer pacing, and a card
/// that gets its cover four minutes in has not really got it.
const SHELF_PATIENCE: Duration = Duration::from_secs(40);
/// A miss that ended in a timeout is asked again sooner than a real one.
const SHORT_MISS: u64 = 3600;
/// The catalog's cover for this track, strictly — remembered either way.
/// Blocks for the lookups; call from a thread of your own. `deep` adds the
/// page's last rung, the artist's whole shelf flattened to tracks — spent
/// on the track someone is listening to, not on every row of a browse:
/// the search rungs answer most rows in half a second, the shelf walk is
/// the expensive exception, and the phone's own walk (which does run it)
/// writes what it finds on the row for the car to read.
pub(crate) fn lookup(
data_dir: &Path,
artist: &str,
title: &str,
duration: Option<f64>,
deep: bool,
) -> Option<(String, Option<u64>)> {
if artist.trim().is_empty() || title.trim().is_empty() {
return None;
}
let key = key_of(artist, title);
let now = now_secs();
let remembered = with_cache(data_dir, |map| map.get(&key).cloned());
if let Some(k) = remembered {
if let Some(url) = k.url {
return Some((url, k.track_id));
}
if k.v >= HUNT_VERSION && now.saturating_sub(k.at) < MISS_TTL {
return None;
}
}
let client = CLIENT.get_or_init(|| std::sync::Arc::new(metadata::MetadataClient::new()));
let want_dur = duration.map(|d| d.round() as u32).filter(|d| *d > 0);
let mut hit: Option<metadata::CatalogTrack> = None;
let mut answered = false;
for q in queries(artist, title) {
let tracks = crate::headless_slsk::rt().block_on(client.catalog_search_tracks(&q, 10));
// the client folds "down" and "nothing" into one empty list; a
// list with anything in it proves the catalog is answering
if !tracks.is_empty() {
answered = true;
*lock(&LAST_ANSWER) = Some(Instant::now());
}
if let Some(t) = pick(&tracks, artist, title, want_dur) {
hit = Some(t.clone());
break;
}
}
// The page's last rung: the artist's whole shelf, flattened to tracks,
// for records the search index buries — Deezer's search answers
// "Nirvana Smells Like Teen Spirit" with six live takes and never the
// studio cut (2026-09-18), while Nevermind's own tracklist has it.
let mut ran_out = false;
if hit.is_none() && deep {
let lead = primary_artist(artist);
let walk = client.catalog_artist_track_candidates(&lead, title);
let shelf = crate::headless_slsk::rt()
.block_on(async { tokio::time::timeout(SHELF_PATIENCE, walk).await.ok() });
match shelf {
Some(tracks) => {
if !tracks.is_empty() {
answered = true;
*lock(&LAST_ANSWER) = Some(Instant::now());
}
hit = pick(&tracks, artist, title, want_dur).cloned();
}
None => {
ran_out = true;
crate::auto_browse::warn(&format!(
"cover hunt: the shelf walk for '{artist}' ran out of time"
));
}
}
}
let found = hit.and_then(|t| t.cover.as_deref().map(|c| (hq_cover(c), Some(t.id))));
let reachable = answered
|| lock(&LAST_ANSWER).is_some_and(|at| at.elapsed() < REACHABLE_FOR);
if found.is_none() && !reachable {
// offline, or the catalog unreachable: not a miss
crate::auto_browse::warn(&format!("cover hunt: catalog unreachable for '{title}'"));
return None;
}
with_cache(data_dir, |map| {
map.insert(
key,
Known {
url: found.as_ref().map(|(u, _)| u.clone()),
track_id: found.as_ref().and_then(|(_, id)| *id),
// a miss cut short by the clock is asked again within the
// hour, not the day
at: if ran_out && found.is_none() { now.saturating_sub(MISS_TTL - SHORT_MISS) } else { now },
v: HUNT_VERSION,
},
);
});
save(data_dir);
match &found {
Some((u, _)) => crate::auto_browse::warn(&format!("cover hunt: '{artist} — {title}' → {u}")),
None => crate::auto_browse::warn(&format!("cover hunt: nothing certain for '{artist} — {title}'")),
}
found
}
/// Write the cover on the row — and on every other stream row of the same
/// track that has none. Only a NULL is ever filled.
fn persist(data_dir: &Path, item_id: i64, artist: &str, title: &str, url: &str, track_id: Option<u64>) {
let db = crate::auto_browse::db_path(data_dir);
let Ok(conn) = rusqlite::Connection::open(&db) else {
return;
};
let _ = conn.busy_timeout(Duration::from_secs(3));
let updated = conn.execute(
"UPDATE playlist_items SET art_url = ?1
WHERE art_url IS NULL AND art_path IS NULL AND kind = 'stream'
AND (id = ?2 OR (lower(artist) = lower(?3) AND lower(title) = lower(?4)))",
rusqlite::params![url, item_id, artist, title],
);
if let Some(id) = track_id {
let _ = conn.execute(
"UPDATE playlist_items SET track_id = ?1
WHERE (track_id IS NULL OR track_id = '') AND kind = 'stream'
AND (id = ?2 OR (lower(artist) = lower(?3) AND lower(title) = lower(?4)))",
rusqlite::params![id.to_string(), item_id, artist, title],
);
}
if let Err(e) = updated {
crate::auto_browse::warn(&format!("cover hunt: could not write row {item_id}: {e}"));
}
}
/* ── the matcher: catalogMatch.js, in Rust ─────────────────────────── */
/// Fold to comparable letters and digits: case, accents and punctuation
/// all differ freely between a tag, a tracklist line and a catalog row.
/// (`nameNorm`: NFKD there; a table of the Latin accents here.)
pub(crate) fn name_norm(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = true;
let push = |c: char, out: &mut String, space: &mut bool| {
if c.is_alphanumeric() {
out.push(c);
*space = false;
} else if !*space {
out.push(' ');
*space = true;
}
};
for c in s.chars() {
if c == '&' {
for a in " and ".chars() {
push(a, &mut out, &mut space);
}
continue;
}
for lc in c.to_lowercase() {
match fold(lc) {
Some(folded) => {
for fc in folded.chars() {
push(fc, &mut out, &mut space);
}
}
None => push(lc, &mut out, &mut space),
}
}
}
out.trim().to_owned()
}
fn fold(c: char) -> Option<&'static str> {
Some(match c {
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
'æ' => "ae",
'ç' | 'ć' | 'č' => "c",
'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ė' | 'ę' | 'ě' => "e",
'ì' | 'í' | 'î' | 'ï' | 'ī' | 'į' => "i",
'ñ' | 'ń' | 'ň' => "n",
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => "o",
'œ' => "oe",
'ù' | 'ú' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' => "u",
'ý' | 'ÿ' => "y",
'ß' => "ss",
'š' | 'ś' => "s",
'ž' | 'ź' | 'ż' => "z",
'ł' => "l",
'đ' => "d",
'ř' => "r",
'ť' => "t",
_ => return None,
})
}
/// A different MASTER of one performance: same recording, repackaged.
const EDITION_WORDS: &[&str] = &[
"remaster", "remastered", "remasterd", "stereo", "mono", "deluxe", "anniversary", "reissue",
"expanded", "digital", "album version", "single version", "original version", "radio edit",
"explicit", "clean", "bonus", "mix master", "from the vault",
];
/// A different PERFORMANCE: another take, another artist's hands on it.
const IDENTITY_WORDS: &[&str] = &[
"remix", "live", "acoustic", "instrumental", "demo", "cover", "karaoke", "unplugged", "reprise",
"vip", "bootleg", "session", "rework", "flip", "mashup", "dub", "edit", "mix", "version", "take",
"alternate", "orchestral", "piano", "8 bit", "tribute", "originally", "made famous",
];
/// `phrase` as whole words inside an already-normalized string.
fn has_phrase(norm: &str, phrase: &str) -> bool {
format!(" {norm} ").contains(&format!(" {phrase} "))
}
fn is_year(norm: &str) -> bool {
norm.len() == 4 && norm.chars().all(|c| c.is_ascii_digit())
}
/// What a qualifier means for identity: "none", a different master
/// ("edition"), or a different performance ("identity"). An unrecognized
/// qualifier counts as a different performance — the strict reading.
fn qualifier_class(qualifier: &str) -> &'static str {
let n = name_norm(qualifier);
if n.is_empty() {
return "none";
}
let first = n.split(' ').next().unwrap_or("");
if ["feat", "featuring", "ft", "with", "w"].contains(&first) {
return "edition";
}
if is_year(&n) {
return "edition";
}
if EDITION_WORDS.iter().any(|w| has_phrase(&n, w)) {
return "edition";
}
"identity"
}
/// "Title (2011 Remaster)" / "Title - Remastered 2011" / "Title -album
/// version-" → the base name and the trailing qualifier(s). One trailing
/// group per pass, repeatedly.
pub(crate) fn split_version(title: &str) -> (String, String) {
let mut base = title.trim().to_owned();
let mut parts: Vec<String> = Vec::new();
for _ in 0..4 {
let before = base.clone();
if let Some((b, q)) = trailing_bracket(&base) {
base = b;
parts.push(q);
continue;
}
if let Some((b, q)) = dashes_both_sides(&base) {
base = b;
parts.push(q);
continue;
}
let dashed = base
.rsplit_once(" - ")
.map(|(b, tail)| (b.trim().to_owned(), tail.trim().to_owned()));
if let Some((b, tail)) = dashed {
let t = name_norm(&tail);
let qualifier = EDITION_WORDS.iter().any(|w| has_phrase(&t, w))
|| IDENTITY_WORDS.iter().any(|w| has_phrase(&t, w))
|| is_year(&t);
if qualifier && !b.is_empty() {
base = b;
parts.push(tail);
continue;
}
}
if base == before {
break;
}
}
parts.reverse();
(base, parts.join(" "))
}
/// "Name (group)" at the very end, the group holding no opening bracket.
fn trailing_bracket(s: &str) -> Option<(String, String)> {
let t = s.trim_end();
let close = t.chars().last()?;
let open = match close {
')' => '(',
']' => '[',
'}' => '{',
_ => return None,
};
let start = t.rfind(open)?;
let inner = &t[start + open.len_utf8()..t.len() - close.len_utf8()];
if inner.contains(['(', '[', '{']) {
return None;
}
let head = t[..start].trim_end();
if head.is_empty() {
return None;
}
Some((head.to_owned(), inner.trim().to_owned()))
}
/// "Title -album version-": dashes on both sides of the tail.
fn dashes_both_sides(s: &str) -> Option<(String, String)> {
let t = s.trim_end();
let t = t.strip_suffix('-')?.trim_end();
let cut = t.rfind(" -")?;
let head = t[..cut].trim_end();
let tail = t[cut + 2..].trim();
if head.is_empty() || tail.is_empty() || tail.contains('-') {
return None;
}
Some((head.to_owned(), tail.to_owned()))
}
/// The comma-separated lead artist; never split on "&" or "and".
fn primary_artist(artist: &str) -> String {
let first = artist.split(',').next().unwrap_or("").trim();
if first.is_empty() {
artist.trim().to_owned()
} else {
first.to_owned()
}
}
/// The act without its backing band: "Joan Jett and the Blackhearts" →
/// "joan jett" (normalized). Only a trailing "and the …" / "with …" comes
/// off, never a bare word.
fn band_core(artist: &str) -> String {
let n = name_norm(artist);
let mut best: Option<usize> = None;
for sep in [" and ", " with "] {
for (i, _) in n.match_indices(sep) {
let rest = &n[i + sep.len()..];
let rest = rest.strip_prefix("the ").unwrap_or(rest);
if i > 0 && !rest.trim().is_empty() {
best = Some(best.map_or(i, |b| b.min(i)));
}
}
}
match best {
Some(i) => n[..i].trim().to_owned(),
None => n,
}
}
/// Do these two artist strings name the same act?
fn artist_agrees(want: &str, cand: &str) -> bool {
let w = name_norm(want);
let c = name_norm(cand);
if w.is_empty() || c.is_empty() {
return false;
}
if w == c {
return true;
}
let wp = name_norm(&primary_artist(want));
let cp = name_norm(&primary_artist(cand));
if wp == c || cp == w || (!wp.is_empty() && wp == cp) {
return true;
}
let wc = band_core(&wp);
let cc = band_core(&cp);
(!wc.is_empty() && (wc == c || wc == cc)) || (!cc.is_empty() && cc == w)
}
/// Durations both sides know that disagree by more than this are
/// different cuts; within [`DURATION_CONFIRM_SECS`] the duration confirms.
const DURATION_TOLERANCE_SECS: u32 = 20;
const DURATION_CONFIRM_SECS: u32 = 7;
/// The best catalog track for the wanted one, or `None` when nothing
/// clears the bar — `pickCatalogMatch`, rule for rule.
pub(crate) fn pick<'a>(
tracks: &'a [metadata::CatalogTrack],
want_artist: &str,
want_title: &str,
want_dur: Option<u32>,
) -> Option<&'a metadata::CatalogTrack> {
let (want_base_raw, want_qual_raw) = split_version(want_title);
let want_base = name_norm(&want_base_raw);
let want_class = qualifier_class(&want_qual_raw);
let want_qual = name_norm(&want_qual_raw);
if want_base.is_empty() || name_norm(want_artist).is_empty() {
return None;
}
let mut best: Option<&metadata::CatalogTrack> = None;
let mut best_score = f64::MIN;
for t in tracks {
if !artist_agrees(want_artist, &t.artist) {
continue;
}
let (cand_base_raw, cand_qual_raw) = split_version(&t.title);
let cand_base = name_norm(&cand_base_raw);
let cand_class = qualifier_class(&cand_qual_raw);
let cand_qual = name_norm(&cand_qual_raw);
if cand_base.is_empty() {
continue;
}
let shorter = cand_base.len().min(want_base.len());
let longer = cand_base.len().max(want_base.len());
let title_score = if cand_base == want_base {
4.0
} else if (cand_base.contains(&want_base) || want_base.contains(&cand_base))
&& shorter >= 6
&& (shorter as f64) / (longer as f64) >= 0.6
{
2.0
} else {
continue;
};
if want_class == "identity" || cand_class == "identity" {
let shared = !want_qual.is_empty()
&& !cand_qual.is_empty()
&& want_qual
.split(' ')
.any(|w| w.len() > 2 && cand_qual.contains(w));
if !shared {
continue;
}
}
let both_know = want_dur.is_some() && t.duration_secs.is_some_and(|d| d > 0);
let delta = if both_know {
Some(t.duration_secs.unwrap_or(0).abs_diff(want_dur.unwrap_or(0)))
} else {
None
};
if let Some(d) = delta {
if d > DURATION_TOLERANCE_SECS {
continue;
}
}
let mut score = title_score;
if delta.is_some_and(|d| d <= DURATION_CONFIRM_SECS) {
score += 2.0;
}
if want_class == cand_class {
score += 1.0;
}
score += (t.rank.unwrap_or(0) as f64 / 1_000_000.0).min(1.0);
if score > best_score {
best_score = score;
best = Some(t);
}
}
best
}
/// Quote-safe value for Deezer's field search syntax.
fn q(s: &str) -> String {
s.replace(['"', '\\'], " ").trim().to_owned()
}
/// The queries to try for one wanted track, strongest first — the page's
/// `catalogQueries`: the precise field syntax with the base name, the
/// band-core spelling, the full name, then the blunt way.
pub(crate) fn queries(artist: &str, title: &str) -> Vec<String> {
let artist_q = q(&primary_artist(artist));
let full = q(title);
let base = q(&split_version(title).0);
let core = q(&band_core(&artist_q));
let mut out = Vec::new();
if !artist_q.is_empty() && !base.is_empty() {
out.push(format!("artist:\"{artist_q}\" track:\"{base}\""));
}
if !core.is_empty() && !base.is_empty() && core != name_norm(&artist_q) {
out.push(format!("artist:\"{core}\" track:\"{base}\""));
}
if !artist_q.is_empty() && !full.is_empty() && full != base {
out.push(format!("artist:\"{artist_q}\" track:\"{full}\""));
}
if !artist_q.is_empty() && !base.is_empty() {
out.push(format!("{artist_q} {base}"));
}
out
}
/// The largest Deezer rendition of a cover URL (`hqCover`).
pub(crate) fn hq_cover(url: &str) -> String {
let host_ok = url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.is_some_and(|h| h.ends_with("dzcdn.net"));
if !host_ok {
return url.to_owned();
}
let Some((head, seg)) = url.rsplit_once('/') else {
return url.to_owned();
};
// "250x250-000000-80-0-0.jpg" → "1000x1000-000000-80-0-0.jpg"
let digits = |s: &str| s.chars().take_while(|c| c.is_ascii_digit()).count();
let w = digits(seg);
if !(2..=4).contains(&w) || seg[w..].chars().next() != Some('x') {
return url.to_owned();
}
let h = digits(&seg[w + 1..]);
if !(2..=4).contains(&h) {
return url.to_owned();
}
let rest = &seg[w + 1 + h..];
let ext_ok = rest.rsplit_once('.').is_some_and(|(_, e)| {
matches!(e.to_ascii_lowercase().as_str(), "jpg" | "png" | "webp")
});
if !ext_ok || !(rest.starts_with('-') || rest.starts_with('.')) {
return url.to_owned();
}
format!("{head}/1000x1000{rest}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_split_like_the_page() {
assert_eq!(split_version("Bohemian Rhapsody - Remastered 2011").0, "Bohemian Rhapsody");
assert_eq!(split_version("Lola (Stereo Remastered 2020) [Bonus]").0, "Lola");
assert_eq!(split_version("Kiss - Detroit Rock City").0, "Kiss - Detroit Rock City");
assert_eq!(split_version("A-Punk").0, "A-Punk");
let (b, qual) = split_version("Parking Lot - VIGILE Remix");
assert_eq!(b, "Parking Lot");
assert_eq!(qualifier_class(&qual), "identity");
}
#[test]
fn artists_agree_like_the_page() {
assert!(artist_agrees("Joan Jett and the Blackhearts", "Joan Jett"));
assert!(artist_agrees("Ennio Morricone, Joan Baez", "Ennio Morricone"));
assert!(!artist_agrees("Queen", "Queen Latifah"));
assert_eq!(band_core("Joan Jett and the Blackhearts"), "joan jett");
}
#[test]
fn covers_upscale() {
assert_eq!(
hq_cover("https://cdn-images.dzcdn.net/images/cover/abc/250x250-000000-80-0-0.jpg"),
"https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
);
assert_eq!(hq_cover("https://i.ytimg.com/vi/x/maxresdefault.jpg"), "https://i.ytimg.com/vi/x/maxresdefault.jpg");
}
}
+1007 -124
View File
File diff suppressed because it is too large. Load diff
+436
View File
@@ -0,0 +1,436 @@
//! A Soulseek session for a process with no page — so a row the phone does
//! not hold yet can be found and streamed from the car.
//!
//! "Needs the app" was the honest label for what the cold service could not
//! do: the resolver and the Soulseek session live in the page, and a car
//! that starts the app cold has no page. Kyle's answer to that label was the
//! right one — a tap should find a copy and play it, in the background —
//! and this is the smallest thing that does it: the stored account signs
//! in from here, one search, the best copy by rules close to the page's
//! (`src/lib/sources.js` songCandidates), a download into the same stream
//! cache the page uses, and the engine plays the bytes as they land, the
//! way `play_stream` does for the page.
//!
//! **One session per process, and the page adopts it.** When the app opens
//! over a car session, `commands::login_inner` takes this client instead of
//! logging in again (two logins from one address in quick succession is
//! what the server throttles), and every event from here on is forwarded to
//! the page's own pump through [`subscribe`]. The download in flight keeps
//! going: dropping a `Client` handle does not tear the connection down.
//!
//! Its own tokio runtime, because there is no Tauri one in a process the
//! car started; the two coexist when the app comes up.
use player::StreamProgress;
use soulseek_client::{Client, Config, DownloadState, Event};
use soulseek_proto::types::SearchResponse;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use crate::auto_browse::warn;
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
/// The handle, until the page adopts it.
static CLIENT: Mutex<Option<Client>> = Mutex::new(None);
/// Signed in, from this process's point of view.
static CONNECTED: Mutex<Option<String>> = Mutex::new(None);
/// token → the responses that came back for it.
static SEARCHES: Mutex<Vec<(u32, Vec<SearchResponse>)>> = Mutex::new(Vec::new());
/// download id → the progress the engine is reading.
static DOWNLOADS: Mutex<Vec<(u64, Arc<StreamProgress>)>> = Mutex::new(Vec::new());
/// The server's excluded search phrases, lowercased — a query containing
/// one returns nothing network-wide.
static EXCLUDED: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Where the page's pump listens once it exists.
static SUBSCRIBER: Mutex<Option<mpsc::UnboundedSender<Event>>> = Mutex::new(None);
pub(crate) fn rt() -> &'static tokio::runtime::Runtime {
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("slsk-cold")
.build()
.expect("a tokio runtime for the cold Soulseek session")
})
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// True once this process has signed in (and the page has not yet adopted
/// the session — after adoption the page's own state is the truth).
pub fn connected() -> bool {
lock(&CONNECTED).is_some() && lock(&CLIENT).is_some()
}
/// The library root, the way auto_browse derives it: `download_dir` from
/// config.json, else `<data_dir>/Library`.
fn library_dir(data_dir: &Path) -> PathBuf {
std::fs::read_to_string(data_dir.join("config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("download_dir").and_then(|d| d.as_str()).map(str::to_owned))
.filter(|s| !s.is_empty())
.map_or_else(|| data_dir.join("Library"), PathBuf::from)
}
/// Sign in with the stored account, once. Blocks the caller — a car tap
/// arrives on a worker thread that may wait — for up to ~20 s.
pub fn ensure_session(data_dir: &Path) -> Result<(), String> {
if connected() {
return Ok(());
}
crate::config::remember_secrets_dir_path(data_dir);
let cfg_json = std::fs::read_to_string(data_dir.join("config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.unwrap_or_default();
let username = cfg_json
.get("username")
.and_then(|u| u.as_str())
.map(str::to_owned)
.filter(|u| !u.is_empty())
.ok_or("no Soulseek account on this phone")?;
let password = crate::config::load_password(&username)
.ok_or("the Soulseek account is not signed in on this phone")?;
let mut cfg = Config::default();
if let Some(p) = cfg_json.get("listen_port").and_then(|p| p.as_u64()) {
cfg.listen_port = p as u16;
}
let downloads = library_dir(data_dir);
cfg.downloads_dir = downloads.clone();
cfg.shared_folders = vec![downloads];
cfg.description = "Shared with SoulStream".into();
let (client, events) = rt()
.block_on(async {
let (client, events) =
tokio::time::timeout(Duration::from_secs(15), Client::connect(cfg))
.await
.map_err(|_| "could not reach the Soulseek server in time".to_string())?
.map_err(|e| format!("could not reach the Soulseek server: {e}"))?;
tokio::time::timeout(Duration::from_secs(15), client.login(&username, &password))
.await
.map_err(|_| "Soulseek did not answer the sign-in in time".to_string())?
.map_err(|e| format!("Soulseek refused the sign-in: {e}"))?;
Ok::<_, String>((client, events))
})?;
warn(&format!("cold soulseek: signed in as {username}"));
*lock(&CLIENT) = Some(client);
*lock(&CONNECTED) = Some(username);
rt().spawn(consume(events));
Ok(())
}
/// The one consumer of the session's events: search replies and download
/// progress land in the tables above, and everything is forwarded to the
/// page's pump once one has subscribed.
async fn consume(mut events: mpsc::UnboundedReceiver<Event>) {
while let Some(ev) = events.recv().await {
match &ev {
Event::SearchResult { token, response } => {
let mut s = lock(&SEARCHES);
if let Some((_, list)) = s.iter_mut().find(|(t, _)| t == token) {
list.push(response.clone());
}
}
Event::Download(u) => {
let progress = lock(&DOWNLOADS)
.iter()
.find(|(id, _)| *id == u.id)
.map(|(_, p)| p.clone());
if let Some(p) = progress {
let (done, failed) = match &u.state {
DownloadState::Complete { .. } => (true, None),
DownloadState::Failed { reason } => (false, Some(reason.clone())),
DownloadState::Cancelled => (false, Some("stream cancelled".to_string())),
_ => (false, None),
};
p.update(u.received, done, failed);
if done {
lock(&DOWNLOADS).retain(|(id, _)| *id != u.id);
}
}
}
Event::ExcludedSearchPhrases(p) => {
*lock(&EXCLUDED) = p.iter().map(|s| s.to_lowercase()).collect();
}
Event::Disconnected { reason } => {
warn(&format!("cold soulseek: disconnected ({reason})"));
*lock(&CONNECTED) = None;
}
_ => {}
}
if let Some(tx) = lock(&SUBSCRIBER).as_ref() {
let _ = tx.send(ev);
}
}
}
/// The page taking the session over: the client handle, and the account.
/// `None` when there is nothing to adopt (the page then signs in itself).
pub fn adopt() -> Option<(Client, String)> {
let client = lock(&CLIENT).take()?;
let user = lock(&CONNECTED).clone()?;
Some((client, user))
}
/// Where the page's pump reads this session's events from after adoption.
pub fn subscribe() -> mpsc::UnboundedReceiver<Event> {
let (tx, rx) = mpsc::unbounded_channel();
*lock(&SUBSCRIBER) = Some(tx);
rx
}
/* ── finding a copy ───────────────────────────────────────────────────── */
/// Streamable, the page's list (RESOLVER_STREAMABLE).
const STREAMABLE: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aiff", "aif"];
const LOSSLESS: [&str; 4] = ["flac", "wav", "aiff", "aif"];
/// Lowercased, punctuation folded to spaces, whitespace collapsed — the
/// page's normText, near enough for a contains() test.
fn norm(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = true;
for c in s.chars().flat_map(|c| c.to_lowercase()) {
if c.is_alphanumeric() {
out.push(c);
space = false;
} else if !space {
out.push(' ');
space = true;
}
}
out.trim().to_owned()
}
/// The page's `simplifyTitle`, for the query: the title up to the first
/// `; : ( [`, and its first six words. The full title still has to be in
/// the filename.
fn simplify(title: &str) -> String {
let first = title
.split(|c| c == ';' || c == ':' || c == '(' || c == '[')
.next()
.unwrap_or("")
.trim();
let base = if first.is_empty() { title } else { first };
let words: Vec<&str> = base.split_whitespace().take(6).collect();
let out = words.join(" ");
if out.is_empty() {
title.to_owned()
} else {
out
}
}
#[derive(Clone, Debug)]
pub struct Pick {
pub username: String,
pub path: String,
pub size: u64,
pub duration: Option<f64>,
pub score: i64,
pub slot: bool,
}
/// The best copies of a track on the network, best first. With
/// `require_identity` — the page's `requireIdentityEvidence`, for a rung
/// that searched by title alone — the artist has to appear somewhere in
/// the peer's path, or the copy is another artist's song of that name.
fn candidates(
responses: &[SearchResponse],
title: &str,
artist: &str,
expect_secs: Option<f64>,
require_identity: bool,
) -> Vec<Pick> {
let want = norm(title);
let want_short = norm(&simplify(title));
let artist_key = norm(artist);
let mut out: Vec<Pick> = Vec::new();
for r in responses {
for f in &r.files {
let name = f.filename.rsplit('\\').next().unwrap_or(&f.filename);
let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
let nname = norm(stem);
if !(nname.contains(&want) || (!want_short.is_empty() && nname.contains(&want_short))) {
continue;
}
let ext = f.extension.to_lowercase();
if !STREAMABLE.contains(&ext.as_str()) {
continue;
}
if f.size == 0 || f.size > 400 * 1024 * 1024 {
continue;
}
let bitrate = f.attributes.iter().find(|(k, _)| *k == 0).map(|(_, v)| *v);
let duration = f.attributes.iter().find(|(k, _)| *k == 1).map(|(_, v)| *v as f64);
if let (Some(want), Some(got)) = (expect_secs, duration) {
// the page's durationMismatch: a copy that is not the same
// recording — a radio edit, a live take — is not this song
if (want - got).abs() > (want * 0.08).max(8.0) {
continue;
}
}
let lossless = LOSSLESS.contains(&ext.as_str());
let mut score: i64 = if lossless { 3000 } else { 1000 };
if let Some(b) = bitrate {
score += (b.min(400) as i64) * 2;
}
if f.size < 800 * 1024 {
score -= 2000; // a snippet, not the song
}
let path_n = norm(&f.filename);
let artist_evidence = !artist_key.is_empty() && path_n.contains(&artist_key);
if require_identity && !artist_key.is_empty() && !artist_evidence {
continue;
}
if artist_evidence {
score += 400;
}
if nname.ends_with(&want) {
score += 250;
}
if r.queue_length > 0 {
score -= (r.queue_length.min(50) as i64) * 20;
}
out.push(Pick {
username: r.username.clone(),
path: f.filename.clone(),
size: f.size,
duration,
score,
slot: r.has_free_upload_slot,
});
}
}
out.sort_by(|a, b| b.score.cmp(&a.score));
// Streaming needs bytes NOW: peers with a free slot first, best within
// each half (the page's rule, learned on "Da Funk" 2026-08-03).
let (free, busy): (Vec<Pick>, Vec<Pick>) = out.into_iter().partition(|p| p.slot);
free.into_iter().chain(busy).collect()
}
fn strip_excluded(query: &str) -> String {
let excluded = lock(&EXCLUDED).clone();
let mut words: Vec<&str> = query.split_whitespace().collect();
for phrase in &excluded {
let lower = query.to_lowercase();
if lower.contains(phrase.as_str()) {
let bad: Vec<&str> = phrase.split_whitespace().collect();
words.retain(|w| !bad.iter().any(|b| w.eq_ignore_ascii_case(b)));
}
}
words.join(" ")
}
/// Every word of the artist still in the query, after the server's
/// excluded phrases came out of it.
fn keeps_artist(query: &str, artist: &str) -> bool {
let q = norm(query);
let words: Vec<&str> = q.split(' ').collect();
norm(artist)
.split(' ')
.filter(|w| !w.is_empty())
.all(|w| words.contains(&w))
}
/// The page's ladder for a song (`streamSong`): artist + simplified title,
/// then the simplified title alone. Each rung is one search, a few seconds
/// of replies, the best copies; the first rung that produces any wins. A
/// rung without the artist in it — the second, or the first once the
/// server's excluded phrases took the artist out — matches by title
/// alone, so the artist has to appear in the peer's path instead.
pub fn find(title: &str, artist: &str, expect_secs: Option<f64>) -> Result<Vec<Pick>, String> {
let simple = simplify(title);
let rungs = [format!("{artist} {simple}"), simple.clone()];
for raw in rungs {
let stripped = strip_excluded(&raw);
let query = if stripped.trim().is_empty() { raw.clone() } else { stripped };
if query.trim().is_empty() {
continue;
}
let require_identity = !keeps_artist(&query, artist);
// the client may spawn onto its runtime from any call; this thread
// is not on it (see `fetch`)
let _rt = rt().enter();
let token = {
let guard = lock(&CLIENT);
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
client.search(&query).map_err(|e| e.to_string())?
};
lock(&SEARCHES).push((token, Vec::new()));
let started = Instant::now();
let mut best: Vec<Pick> = Vec::new();
let mut files = 0usize;
// Up to eight seconds; done early once a dozen usable copies are in.
while started.elapsed() < Duration::from_secs(8) {
std::thread::sleep(Duration::from_millis(400));
let responses = lock(&SEARCHES)
.iter()
.find(|(t, _)| *t == token)
.map(|(_, r)| r.clone())
.unwrap_or_default();
files = responses.iter().map(|r| r.files.len()).sum();
best = candidates(&responses, title, artist, expect_secs, require_identity);
if best.len() >= 12 && started.elapsed() > Duration::from_millis(2500) {
break;
}
}
if let Some(client) = lock(&CLIENT).as_ref() {
client.stop_search(token);
}
lock(&SEARCHES).retain(|(t, _)| *t != token);
warn(&format!("cold soulseek: '{query}' → {files} files, {} copies", best.len()));
if !best.is_empty() {
return Ok(best);
}
}
Ok(Vec::new())
}
/// Start fetching a peer's copy into the stream cache. Returns the cache
/// path, the partial path the bytes land in, the progress the engine reads,
/// and the download id.
pub fn fetch(
data_dir: &Path,
username: &str,
path: &str,
size: u64,
) -> Result<(PathBuf, PathBuf, Arc<StreamProgress>, u64), String> {
let dir = crate::playback::cache_dir_in(data_dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let cache = crate::playback::cache_file_in(&dir, username, path);
let partial = crate::playback::partial_path(&cache);
let _ = std::fs::remove_file(&partial);
let progress = StreamProgress::new();
let id = {
// `download_to` spawns the transfer task with `tokio::spawn`, which
// needs a runtime on the CALLING thread — and the caller here is
// the engine's resolver thread, not a Tauri command. Without this
// the spawn panics, the thread dies without a word (a cold process
// has no stderr in logcat), and the card sits on BUFFERING for as
// long as anyone looks (2026-09-18, the first cold fetch).
let _rt = rt().enter();
let guard = lock(&CLIENT);
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
client.download_to(username, path, size, cache.clone())
};
lock(&DOWNLOADS).push((id, progress.clone()));
Ok((cache, partial, progress, id))
}
/// Give up on a fetch that never delivered.
pub fn cancel(id: u64) {
let _rt = rt().enter();
if let Some(client) = lock(&CLIENT).as_ref() {
client.cancel_download(id);
}
lock(&DOWNLOADS).retain(|(i, _)| *i != id);
}
+29
View File
@@ -11,6 +11,15 @@ mod commands;
/// process that has no Activity — and so there is never a second one.
#[cfg(target_os = "android")]
mod headless;
/// A Soulseek session for a process with no page, so a row the phone does
/// not hold yet is found and streamed from the car — and adopted by the
/// page when the app opens over it.
#[cfg(target_os = "android")]
mod headless_slsk;
/// Covers for rows the catalog was never asked about, from the process
/// with no page to ask — the page's cover walk, in Rust, for the car.
#[cfg(target_os = "android")]
mod cover_hunt;
mod community;
mod config;
mod cover_logo;
@@ -36,6 +45,12 @@ mod lyrics;
mod paths;
mod pipeline;
mod playback;
/// Where playback was, on disk, for a process with no page to ask: media
/// resumption, a head unit's PLAY into a cold app, Android Auto's autoplay.
mod resume;
/// The remembered peer file of every streamed track, mirrored to disk so the
/// cold browse can tell which cache files are whole playlist rows.
mod stream_sources;
mod profile;
mod playlist_formats;
mod playlists;
@@ -115,6 +130,13 @@ pub fn run() {
thumbbar::init(app.handle());
playback::init(app.handle());
playback::prune_cache(app.handle());
// Hover previews are the desktop's (ANDROID.md, "Off by design"),
// and their init opens TWO more output streams — on the phone
// that was three live AAudio streams per launch for one that
// ever sounds (seen 2026-09-18 in `dumpsys audio`). The phone
// never calls a preview command, so nothing here misses the
// state.
#[cfg(not(target_os = "android"))]
preview::init(app.handle());
loudness::init(app.handle());
diagnostics::init(app.handle());
@@ -355,6 +377,13 @@ pub fn run() {
commands::preload_cached_stream,
commands::stream_waveform,
commands::stream_cache_ready,
commands::playback_note_queue,
commands::playback_resume_point,
commands::playlist_offline_list,
commands::playlist_offline_set,
commands::debug_drop_connection,
stream_sources::stream_source_remember,
stream_sources::stream_source_keep,
freenet::freenet_status,
freenet::freenet_set_enabled,
freenet::freenet_detect,
+88 -8
View File
@@ -157,6 +157,13 @@ pub struct PlayerState {
/// "what is the audio thread doing right now", mirrored here as the events
/// go past and read back by `player_state` at boot.
pub last_transport: Mutex<PlaybackSnapshot>,
/// The queue a CAR was driving when the app came up over it (Android
/// only; always `None` elsewhere). `init` takes it off the headless
/// engine at the moment the page takes the queue over, and the page's
/// `restoreSession` builds its queue from it instead of from its own
/// localStorage — which would describe an older session and put the
/// wrong title over the right audio. Read once through `player_state`.
pub handover: Mutex<Option<crate::resume::ResumePoint>>,
}
/// What the audio thread is doing, as of its last event. Deliberately only
@@ -186,8 +193,64 @@ enum PlayerUiEvent {
/// Create the audio thread and forward its events to the webview.
pub fn init(app: &tauri::AppHandle) {
let output_device = crate::config::load(app).audio_output_device;
let (player, events) = Player::spawn_with_device(output_device);
/* ANDROID TAKES THE PROCESS'S ONE ENGINE, it does not make a second.
A car can start this app by binding the media service, play a record
into a process with no Activity, and have the user open the app on top
of it minutes later — and `Player::spawn` opens an output stream, so a
second one here is two AAudio streams playing over each other out of one
process. `headless::audio` builds it on first use, whoever gets there
first, and hands back the same one afterwards.
The engine's event receiver is single-consumer and the headless pump
already owns it, so the UI subscribes rather than reading it directly;
and the fallback queue is cleared, because from here the JS decides what
plays next and two things advancing one engine would skip a track each
time. Nothing about the desktop changes. */
#[cfg(target_os = "android")]
let (player, events, handover, first) = {
if let Ok(dir) = crate::paths::app_data(app) {
crate::headless::set_data_dir(&dir);
}
let audio = crate::headless::audio(output_device.clone());
let (tx, rx) = std::sync::mpsc::channel();
audio.subscribe(tx);
// If a car was driving this engine, the page is about to take over a
// queue it has never heard of. Hand it the queue BEFORE clearing it,
// so the page can pick it up where it is rather than restoring an
// older session of its own over live audio.
let handover = if audio.driving() { audio.resume_point() } else { None };
audio.clear_queue();
// What the engine is doing RIGHT NOW, for a page that is about to
// ask. The snapshot below is otherwise built from the events that
// arrive after this subscription — and an engine a car started was
// playing before there was anyone to tell, so no Playing ever
// arrives: the page read "idle", called the handover a paused
// session that needed re-sourcing, and pushed PAUSED to the card
// over audio that kept going (2026-09-18, Strictly Acid at 21 s).
let (position, sounding) = audio.progress();
let first = PlaybackSnapshot {
status: if sounding { "playing".into() } else { "idle".into() },
position: if sounding { position } else { 0.0 },
duration: None,
};
(audio.player.clone(), rx, handover, first)
};
#[cfg(not(target_os = "android"))]
let (player, events, handover, first) = {
let (p, e) = Player::spawn_with_device(output_device);
(
p,
e,
None,
PlaybackSnapshot {
status: "idle".into(),
..Default::default()
},
)
};
app.manage(PlayerState {
handover: Mutex::new(handover),
player,
streams: Mutex::new(HashMap::new()),
stream_caches: Mutex::new(HashMap::new()),
@@ -196,10 +259,7 @@ pub fn init(app: &tauri::AppHandle) {
waveform_jobs: Mutex::new(HashMap::new()),
preloads: Mutex::new(HashMap::new()),
deferred_saves: Mutex::new(HashMap::new()),
last_transport: Mutex::new(PlaybackSnapshot {
status: "idle".into(),
..Default::default()
}),
last_transport: Mutex::new(first),
});
let handle = app.clone();
@@ -346,11 +406,23 @@ impl PlayerState {
}
}
/// The stream cache under an instance's data directory. Pure, so a process
/// that has no `AppHandle` (the media service, cold) reaches the same
/// files the app does.
pub fn cache_dir_in(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("stream-cache")
}
/// Stable cache filename for a remote file: short hash + original name.
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
let dir = crate::paths::app_data(app).ok()?.join("stream-cache");
let dir = cache_dir_in(&crate::paths::app_data(app).ok()?);
std::fs::create_dir_all(&dir).ok()?;
Some(cache_file_in(&dir, username, remote_path))
}
/// The same name, computed without touching the disk. `stream_sources.rs`
/// uses it to ask whether a remembered peer file is already here.
pub fn cache_file_in(dir: &std::path::Path, username: &str, remote_path: &str) -> PathBuf {
let mut hash: u64 = 0xcbf29ce484222325;
for b in username.bytes().chain(remote_path.bytes()) {
hash ^= b as u64;
@@ -365,7 +437,7 @@ pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) ->
c => c,
})
.collect();
Some(dir.join(format!("{hash:016x} {safe}")))
dir.join(format!("{hash:016x} {safe}"))
}
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
@@ -398,12 +470,17 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// a transfer buffer — age expiry off, size cap still applies.
let age_prune = !crate::config::stream_keep_forever(&cfg);
let Ok(dir) = crate::paths::app_data(app).map(|d| d.join("stream-cache")) else {
let Ok(data_dir) = crate::paths::app_data(app) else {
return;
};
let dir = cache_dir_in(&data_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// A track fetched because its playlist is kept offline is not a cache
// entry, it is the offline copy — the retention window and the size cap
// both leave it alone, or "keep offline" would quietly stop being true.
let kept = crate::stream_sources::kept_cache_files(&data_dir);
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
@@ -417,6 +494,9 @@ pub fn prune_cache(app: &tauri::AppHandle) {
// entries. Deleting one can make its final rename fail.
return None;
}
if kept.contains(&path) {
return None;
}
let md = e.metadata().ok()?;
if !md.is_file() {
return None;
+143
View File
@@ -0,0 +1,143 @@
//! Where playback was — on disk, in a shape a process with no page can
//! turn back into music.
//!
//! The phone's player keeps its session in the WebView's localStorage: the
//! queue, the index, the position. That is the right home for the page's
//! own restart, and the wrong one for everything else that can ask "what
//! was playing?": Android's own media resumption (the card in Quick
//! Settings after a reboot), a Bluetooth head unit sending PLAY to an app
//! that is not running, and Android Auto's autoplay on connection, which
//! is exactly the same media-button PLAY arriving through media3's
//! `MediaButtonReceiver` into a cold `MediaService`. None of those can
//! read localStorage. All of them read this.
//!
//! Two writers, one file:
//! · the page, through `playback_note_queue`, whenever it writes its own
//! session — a track change, a queue edit, every ten seconds while
//! playing, and on its way to the background;
//! · the engine's own pump (`headless.rs` on Android), which knows the
//! true position at all times and the whole queue when it is the one
//! driving (a car started the process and there is no page).
//!
//! An entry names what it needs to play again with nothing else alive: a
//! local file, or the peer file whose bytes may already be in the stream
//! cache. Resolving that is the reader's job (`headless::resume_queue`),
//! because whether a cache file is complete is only knowable at the moment
//! of asking.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The peer identity of a streamed entry, enough to find its cache file.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamRef {
pub username: String,
pub path: String,
pub size: u64,
}
/// One queue entry as the page (or the headless queue) described it.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumeItem {
/// The page's own id for the entry (a library track id, `cat:<n>`,
/// `pl<n>`), carried back so a handover keeps the identity.
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub album: String,
#[serde(default)]
pub duration: Option<f64>,
/// A cover: a local path, or an https URL for a catalog record.
#[serde(default)]
pub art: Option<String>,
/// A file on this device — a library track, or a stream that was
/// already served from its cache file.
#[serde(default)]
pub path: Option<String>,
/// A peer file; plays cold only when its cache is byte-complete.
#[serde(default)]
pub stream: Option<StreamRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct ResumePoint {
#[serde(default)]
pub items: Vec<ResumeItem>,
#[serde(default)]
pub idx: usize,
/// Seconds into `items[idx]`.
#[serde(default)]
pub position: f64,
/// What the queue was built from — the record's or playlist's name —
/// for the card's kicker and the car's "recent" row.
#[serde(default)]
pub from: String,
/// Unix seconds of the last write.
#[serde(default)]
pub updated_at: u64,
}
/// The page sends at most this many entries either side of the current
/// one. A two-thousand-track queue is not a thing to write every ten
/// seconds, and a resumed session that runs out after fifty tracks has
/// resumed.
pub const WINDOW: usize = 25;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("resume.json")
}
pub fn load(data_dir: &Path) -> Option<ResumePoint> {
let s = std::fs::read_to_string(file(data_dir)).ok()?;
let rp: ResumePoint = serde_json::from_str(&s).ok()?;
if rp.items.is_empty() {
return None;
}
Some(rp)
}
pub fn save(data_dir: &Path, rp: &ResumePoint) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(rp) {
// Beside-and-rename, so a cold reader never sees half a file.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_and_empty_reads_as_none() {
let d = std::env::temp_dir().join(format!("ss-resume-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
assert!(load(&d).is_none());
let rp = ResumePoint {
items: vec![ResumeItem { title: "One".into(), path: Some("/a.flac".into()), ..Default::default() }],
idx: 0,
position: 12.5,
from: "Discovery".into(),
updated_at: 1,
};
save(&d, &rp);
assert_eq!(load(&d), Some(rp));
save(&d, &ResumePoint::default());
assert!(load(&d).is_none(), "an empty queue is no resume point");
}
}
+43
View File
@@ -9,6 +9,9 @@ pub struct AppState {
/// never routes it — so the resolver strips these terms up front instead
/// of discovering the silence by timeout. Lowercased on receipt.
pub excluded_search_phrases: Mutex<Vec<String>>,
/// The session the user asked for, kept so a drop can be dialled back.
/// See [`Reconnect`] and `commands::schedule_reconnect`.
pub reconnect: std::sync::Mutex<Reconnect>,
/// The experimental Freenet layer's one handle (default: disabled, inert).
/// The privilege boundary: node operations never leave the Rust side.
pub freenet: freenet_adapter::FreenetAdapter,
@@ -16,3 +19,43 @@ pub struct AppState {
/// started by hand is not here — the adapter finds it all the same.
pub freenet_node: std::sync::Mutex<Option<std::process::Child>>,
}
/// What the app remembers about the session the user wanted, so that losing
/// it is followed by getting it back rather than by a grey "offline" the
/// user has to notice and tap through.
///
/// A Soulseek server connection dies for reasons that have nothing to do
/// with the user: the phone changes networks, Android freezes a backgrounded
/// process for long enough that the five-minute keepalive never goes out and
/// the server closes the socket, a laptop sleeps. Until 2026-09-18 every one
/// of those ended the same way — `Event::Disconnected`, the page stamped
/// offline, and every streamed track after that refused with "sign in on
/// Community" until somebody opened the login card again. On a phone in a
/// car that "somebody" does not exist.
///
/// So the backend keeps dialling, with a backoff the server tolerates. The
/// throttle is real and was paid for on 2026-08-03: eight to ten rapid
/// logins from one address and the server silently drops the handshake, and
/// every attempt during that silence reads as "timed out" and extends it.
/// The schedule is 5 s, 15 s, 45 s, 2 min, then 5 min for as long as it
/// takes, with jitter — eight attempts span a quarter of an hour, which is
/// well under the rate that trips it.
#[derive(Default)]
pub struct Reconnect {
/// True from a successful login until the user disconnects on purpose.
/// The only thing that makes a drop worth redialling.
pub wanted: bool,
/// Bumped by every explicit connect and disconnect. A redial loop, and
/// the event pump of the session it belongs to, both carry the
/// generation they were born with and stand down when it moves — which
/// is how a manual re-login never races the automatic one.
pub generation: u64,
/// Consecutive redials since the last session that stayed up. Reset by a
/// drop that follows a stable session, kept by a drop that follows a
/// redial that lasted seconds — so a flapping link keeps backing off.
pub attempt: u32,
/// When the current (or last) session logged in, unix seconds.
pub connected_at: u64,
/// A redial loop is running for this generation.
pub dialing: bool,
}
+309
View File
@@ -0,0 +1,309 @@
//! The remembered peer copy of every streamed track — on disk, where a
//! process with no WebView can read it.
//!
//! The phone's resolver remembers which peer file it last played for a
//! catalog track (`src/lib/sources.js`, `soulstream.streamSources.v1`), and
//! that memory is what lets a track play again from the stream cache with no
//! search at all. It lived in the WebView's localStorage, which meant it
//! existed only while a WebView did: Android Auto starting the app cold —
//! no Activity, no page — could see the cache files sitting in
//! `stream-cache/` and had no way to tell which playlist row any of them
//! belonged to. Every streamed row was "needs the app", even the ones whose
//! bytes were all there.
//!
//! This is the same memory, mirrored to `<data_dir>/stream-sources.json` by
//! the page every time it remembers a source, and read back here by the
//! headless browse tree and the cold player. The page stays the writer of
//! record for its own store; this file is a second copy that the Rust side
//! owns, and the two are keyed identically so a row found by either agrees
//! with the other.
//!
//! Also the home of `keep`: a source fetched because its playlist is kept
//! offline is marked, and `playback::prune_cache` leaves that file alone
//! however old it gets. Without it the retention window (thirty days
//! unplayed, two gigabytes) would quietly undo an offline playlist.
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// One remembered peer file. `size` is the peer-stated size, which is also
/// how a byte-complete cache file is recognised.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StreamSource {
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// Pinned by an offline playlist; the cache pruner skips it.
#[serde(default)]
pub keep: bool,
}
/// What the page sends when it remembers (or re-sends) a source.
#[derive(Debug, Clone, Deserialize)]
pub struct RememberArg {
pub track_id: Option<String>,
pub artist: Option<String>,
pub title: Option<String>,
pub username: String,
pub path: String,
pub size: u64,
#[serde(default)]
pub duration: Option<f64>,
/// `None` leaves an existing pin as it was; `Some` sets it.
#[serde(default)]
pub keep: Option<bool>,
}
/// More than the page keeps (400): this file also holds the offline pins,
/// and a pinned source must never be the one an unpinned one pushes out.
const CAP: usize = 1200;
pub fn file(data_dir: &Path) -> PathBuf {
data_dir.join("stream-sources.json")
}
/// The lookup keys for a track, the page's own rule (`streamSourceKeys`):
/// the catalog id when there is one, and the lowercased `artist|title`.
pub fn keys(track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Vec<String> {
let mut out = Vec::with_capacity(2);
if let Some(t) = track_id.map(str::trim).filter(|t| !t.is_empty()) {
out.push(format!("t:{t}"));
}
if let (Some(a), Some(t)) = (artist, title) {
if !a.is_empty() && !t.is_empty() {
out.push(format!("n:{a}|{t}").to_lowercase());
}
}
out
}
/// Insertion-ordered so the cap can drop the oldest memory first — a
/// BTreeMap would forget which was old.
pub fn load(data_dir: &Path) -> Vec<(String, StreamSource)> {
std::fs::read_to_string(file(data_dir))
.ok()
.and_then(|s| serde_json::from_str::<Vec<(String, StreamSource)>>(&s).ok())
.unwrap_or_default()
}
fn save(data_dir: &Path, entries: &[(String, StreamSource)]) {
let path = file(data_dir);
if let Ok(json) = serde_json::to_string(entries) {
// Written beside and renamed over: a cold browse may be reading the
// file at this exact moment, and it must see the old whole file or
// the new whole file, never half of one.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Remember these sources. Every key of an entry maps to the same record;
/// an existing key is moved to the end (newest), and `keep` is carried
/// forward from the old record unless the argument sets it.
pub fn remember(data_dir: &Path, args: &[RememberArg]) {
if args.is_empty() {
return;
}
let mut entries = load(data_dir);
for a in args {
if a.username.is_empty() || a.path.is_empty() || a.size == 0 {
continue;
}
for key in keys(a.track_id.as_deref(), a.artist.as_deref(), a.title.as_deref()) {
let old_keep = entries
.iter()
.position(|(k, _)| *k == key)
.map(|i| entries.remove(i).1.keep)
.unwrap_or(false);
entries.push((
key,
StreamSource {
username: a.username.clone(),
path: a.path.clone(),
size: a.size,
duration: a.duration,
keep: a.keep.unwrap_or(old_keep),
},
));
}
}
if entries.len() > CAP {
// Drop the oldest UNPINNED memories first; a pin outlives the cap.
let over = entries.len() - CAP;
let mut dropped = 0;
entries.retain(|(_, s)| {
if dropped < over && !s.keep {
dropped += 1;
false
} else {
true
}
});
}
save(data_dir, &entries);
}
/// Pin or unpin the source remembered for a track.
pub fn set_keep(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>, keep: bool) {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return;
}
let mut entries = load(data_dir);
let mut changed = false;
for (k, s) in entries.iter_mut() {
if wanted.contains(k) && s.keep != keep {
s.keep = keep;
changed = true;
}
}
if changed {
save(data_dir, &entries);
}
}
/// The remembered source for a track, catalog id first.
pub fn lookup(data_dir: &Path, track_id: Option<&str>, artist: Option<&str>, title: Option<&str>) -> Option<StreamSource> {
let wanted = keys(track_id, artist, title);
if wanted.is_empty() {
return None;
}
let entries = load(data_dir);
for key in &wanted {
if let Some((_, s)) = entries.iter().find(|(k, _)| k == key) {
return Some(s.clone());
}
}
None
}
/// The cache file this source would land in, if its bytes are all there.
/// `None` for a missing file or a mid-transfer prefix — a prefix is not
/// the track, and a car that started one would stop mid-song.
pub fn cached_file(data_dir: &Path, src: &StreamSource) -> Option<PathBuf> {
let file = crate::playback::cache_file_in(&crate::playback::cache_dir_in(data_dir), &src.username, &src.path);
match std::fs::metadata(&file) {
Ok(md) if src.size > 0 && md.len() == src.size => Some(file),
_ => None,
}
}
/// Every cache file a pinned source names — the pruner's exemption list.
pub fn kept_cache_files(data_dir: &Path) -> HashSet<PathBuf> {
let dir = crate::playback::cache_dir_in(data_dir);
load(data_dir)
.into_iter()
.filter(|(_, s)| s.keep)
.map(|(_, s)| crate::playback::cache_file_in(&dir, &s.username, &s.path))
.collect()
}
/// Where this instance keeps its data — the same directory the config and
/// the stream cache live in, which on Android is also the `dataDir` the
/// media service hands to the cold browse.
fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
crate::paths::app_data(app)
}
/// The page remembering a source (one), or re-sending its whole store at
/// boot so memories made before this file existed reach the cold side.
#[tauri::command]
pub fn stream_source_remember(app: tauri::AppHandle, entries: Vec<RememberArg>) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || remember(&dir, &entries));
Ok(())
}
/// Pin or unpin a track's remembered source for the cache pruner.
#[tauri::command]
pub fn stream_source_keep(
app: tauri::AppHandle,
track_id: Option<String>,
artist: Option<String>,
title: Option<String>,
keep: bool,
) -> Result<(), String> {
let dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || {
set_keep(&dir, track_id.as_deref(), artist.as_deref(), title.as_deref(), keep)
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!("ss-stream-sources-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn keys_follow_the_page() {
assert_eq!(
keys(Some("42"), Some("Hermeth"), Some("Once Again")),
vec!["t:42".to_string(), "n:hermeth|once again".to_string()]
);
assert_eq!(keys(None, Some("A"), None), Vec::<String>::new());
}
#[test]
fn remember_then_lookup_by_either_key_and_keep_survives() {
let d = tmp();
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: Some("Hermeth".into()),
title: Some("Strictly Acid".into()),
username: "peer".into(),
path: "a\\b.flac".into(),
size: 10,
duration: Some(280.0),
keep: None,
}],
);
assert_eq!(lookup(&d, Some("7"), None, None).unwrap().path, "a\\b.flac");
assert_eq!(lookup(&d, None, Some("hermeth"), Some("STRICTLY ACID")).unwrap().size, 10);
set_keep(&d, Some("7"), None, None, true);
// a re-remember without `keep` carries the pin forward
remember(
&d,
&[RememberArg {
track_id: Some("7".into()),
artist: None,
title: None,
username: "peer2".into(),
path: "c\\d.flac".into(),
size: 11,
duration: None,
keep: None,
}],
);
let s = lookup(&d, Some("7"), None, None).unwrap();
assert_eq!(s.username, "peer2");
assert!(s.keep);
assert_eq!(kept_cache_files(&d).len(), 1);
}
#[test]
fn cached_file_wants_the_whole_track() {
let d = tmp();
let src = StreamSource { username: "u".into(), path: "x\\y.mp3".into(), size: 3, duration: None, keep: false };
assert!(cached_file(&d, &src).is_none());
let f = crate::playback::cache_file_in(&crate::playback::cache_dir_in(&d), "u", "x\\y.mp3");
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(&f, b"ab").unwrap();
assert!(cached_file(&d, &src).is_none(), "a prefix is not the track");
std::fs::write(&f, b"abc").unwrap();
assert_eq!(cached_file(&d, &src), Some(f));
}
}
+21
View File
@@ -448,6 +448,27 @@ export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
/** The page's memory of a streamed track's peer file, mirrored to the Rust
* side (`stream_sources.rs`) so a process with no page — the media service
* a car starts — can tell which cache files are whole playlist rows. One
* entry when a source is remembered, the whole store once at boot. */
export const streamSourceRemember = (entries) => invoke("stream_source_remember", { entries });
/** Pin (or unpin) a track's remembered source for the cache pruner. */
export const streamSourceKeep = (trackId, artist, title, keep) =>
invoke("stream_source_keep", { trackId: trackId != null ? String(trackId) : null, artist, title, keep });
/** Name the queue for the resume point (`resume.rs`): what a cold PLAY
* would put back on. */
export const playbackNoteQueue = (items, idx, position, from) =>
invoke("playback_note_queue", { items, idx, position, from });
/** Where playback was left, as the Rust side holds it — null when nothing. */
export const playbackResumePoint = () => invoke("playback_resume_point");
/** The playlists kept offline, and the switch. */
export const playlistOfflineList = () => invoke("playlist_offline_list");
export const playlistOfflineSet = (id, keep) => invoke("playlist_offline_set", { id, keep });
/** Tell the media session that a row the car handed over did not play. */
export const remoteFailed = (message) => invoke("plugin:media-session|remote_failed", { failure: { message } });
/** DEBUG builds only: sever the server connection like a network drop would. */
export const debugDropConnection = () => invoke("debug_drop_connection");
export const streamCacheReady = (username, path, size) =>
invoke("stream_cache_ready", { username, path, size });
+247
View File
@@ -0,0 +1,247 @@
// The artist-identity pass, as pure functions: what MusicBrainz (and the
// MB-verified Apple catalogue) add to a Deezer artist page, and how those
// extras are merged into it — the supplemented releases, the authoritative
// release types, the guest-appearance and co-tenant arbitration.
//
// It lived inside state.svelte.js, which only the desktop loads; the phone
// (src/mobile/catalog.svelte.js) built its artist pages from the Deezer
// list alone and My Bloody Valentine had four releases there. Both surfaces
// read this ONE implementation now — a second copy would only guarantee
// they disagree in new ways (the Now Playing panel learned that first).
import { asciiFold, normText } from "./sources.js";
export const artistIdentityKey = (value) => {
const text = typeof value === "string" ? value : value?.name;
return (text ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
};
/** Accept an exact artist credit or an explicit collaboration credit containing
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
* "Crazy Penis"). Missing credit metadata is retained because there is no
* identity signal to evaluate. */
export function recordBelongsToArtist(record, artistName) {
const credited = typeof record?.artist === "string"
? record.artist
: record?.artist?.name;
if (!credited) return true;
const expected = artistIdentityKey(artistName);
if (!expected) return true;
if (artistIdentityKey(credited) === expected) return true;
return credited
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
.some((part) => artistIdentityKey(part) === expected);
}
export function artistScopedRecords(records, artistName) {
return Array.isArray(records)
? records.filter((record) => recordBelongsToArtist(record, artistName))
: [];
}
/** The identity merge itself, as a pure function of the page and the identity
* payload.
*
* Extracted so the Now Playing panel can run the SAME merge the artist page
* runs. It used to live inside `applyArtistIdentity`, which only the artist
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
* appeared with four releases because Loveless and Isn't Anything exist only
* as MusicBrainz supplements, and Weezer filed differently because the panel
* never received `type_hints` either. Both surfaces read one implementation
* now; a second copy would only guarantee they disagree in new ways.
*/
export function mergeArtistIdentity(current, identity) {
// MB identity extras are always albums, so only rows that could BE
// that album may block one: a same-title SINGLE is a different
// release (the "Phantom Island" shape — a 2024 single and a 2025
// album), and letting it occupy the title made the real album vanish
// from the page.
const seen = new Set(
(current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => normText(a.title)),
);
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
// An MB row whose title is a PREFIX of an existing row's full title
// is the same work Deezer carries as a qualified variant —
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
// (Deluxe)". Injecting it adds a second card with no track count.
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
// distinct: a bare numeral/volume marker never counts as a variant
// qualifier. normText already strips brackets, so compare against
// the raw title normalized without stripping.
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
// Same single-typed exemption as `seen`: only album-like rows may
// claim to be a variant of an MB album.
const existingRaw = (current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => rawNorm(a.title));
const sequelTail = (tail) =>
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
tail.trim(),
);
const coveredByVariant = (title) => {
const base = rawNorm(title);
if (base.length < 8) return false;
return existingRaw.some(
(t) =>
t.length > base.length
&& t.startsWith(base)
&& !sequelTail(t.slice(base.length)),
);
};
// `normText` DELETES bracket contents, which is right for peer file
// matching and wrong here: MusicBrainz writes a qualifier after a colon
// where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced
// to "worldwar3" while MB's "World War 3: Lean" reduced to
// "worldwar3lean" and the two never collided. The result was a second
// card for a record already on the page — and once mixtapes had a tab
// of their own, the same work appeared in Albums AND Mixtapes.
//
// This key keeps the qualifier and instead folds the three things the
// providers genuinely disagree about: the separator, the words
// introducing a number, and how that number is written. Measured on
// Gucci Mane it collapses ten such pairs, among them
// "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five",
// "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" /
// "Trap House Four".
//
// Single-letter roman numerals are deliberately NOT folded: "X", "V"
// and "I" are real album titles, and reading them as 10, 5 and 1 would
// merge unrelated records.
const ORDINALS = {
one: "1", two: "2", three: "3", four: "4", five: "5",
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9",
};
const releaseKey = (title) =>
asciiFold(title ?? "")
.toLowerCase()
.replace(/[‘’']/g, "")
.replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(" ")
.map((word) => ORDINALS[word] ?? word)
.join("");
// Every album-like row already on the page, by that key, so a matched
// MB row can be recognised as the one on screen rather than injected
// beside it.
// Every row sharing a key, not just the first: a provider sometimes
// carries the same record twice under its own spellings ("Brick Factory
// 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put
// one copy in Mixtapes and leave the other in Albums, which is a worse
// page than leaving both alone.
const existingByKey = new Map();
for (const album of current.albums ?? []) {
if (album.record_type === "single") continue;
const key = releaseKey(album.title);
if (!key) continue;
if (!existingByKey.has(key)) existingByKey.set(key, []);
existingByKey.get(key).push(album);
}
// A MusicBrainz mixtape that IS a row already on the page contributes
// its TYPE, not a card. The Deezer row wins the slot because it is the
// playable one — it carries a real album id and tracklist — and MB's
// Mixtape/Street verdict re-files it, exactly as `type_hints` re-files
// a mislabelled compilation. That is what moves "World War 3 (Lean)"
// out of Albums instead of leaving one copy in each tab.
const adoptedMixtapeIds = new Set();
const extra = identityAlbums.filter((a) => {
if (!a?.title) return false;
const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined;
if (twins?.length) {
for (const twin of twins) {
if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id);
}
return false;
}
return (
!seen.has(normText(a.title))
&& !existingByKey.has(releaseKey(a.title))
&& !coveredByVariant(a.title)
);
});
const socials = identity?.socials ?? [];
// MB's authoritative primary/secondary types. The view matches these
// to displayed releases by title and re-files any the provider
// mislabeled "album" (compilations, soundtracks…).
const typeHints = identity?.type_hints ?? [];
// Guest appearances, dropped only where MusicBrainz can arbitrate.
//
// Deezer's /artist/{id}/albums returns records the artist merely
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
// Thief's discography that way. The backend flags them (`appears_on`:
// the release's primary artist is someone else), but that flag ALONE
// must never delete a row: Deezer marks every contributor "Main", so a
// guest spot looks exactly like a genuine joint album — Kanye West's
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
// erased from his page.
//
// MusicBrainz separates them, because it credits a real collaboration
// under BOTH artists: querying Kanye's own MBID returns "Watch the
// Throne", while Mid-Air Thief's returns only his two real albums. So a
// flagged row survives if MB credits it and is dropped if MB does not
// — and ONLY when MB actually answered for this artist. No MB albums
// means no arbiter, and every row is kept rather than guessed at, which
// keeps thin-coverage artists (the obscure end, where this matters
// most) from losing real releases.
//
// The title check alone is not enough, and Kanye is the proof. MB files
// a collaboration under both parties only when the credit NAMES both
// ("Watch the Throne"); when the collaboration is its own MB entity it
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
// appears anywhere in Ye's 315 release groups. All three were being
// erased from his page by the very rule written to protect them.
// `owned_album_ids` is the backend's wider answer to the same question
// — MB release groups of every type plus the MB-verified Apple
// catalogue — so a real collaboration survives on evidence instead of
// on MusicBrainz happening to duplicate the credit.
//
// `foreign_album_ids` is the other half: releases that belong to a
// DIFFERENT act sharing this Deezer artist record. Deezer files several
// unrelated performers under one id (there is exactly one Deezer
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
// Indonesian act and four more), which no amount of search ranking or
// credit-role reading can undo. The backend only fills this in when it
// can NAME the co-tenant, so on an ordinary artist it is empty and the
// page is untouched.
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
const owned = new Set(identity?.owned_album_ids ?? []);
const foreign = new Set(identity?.foreign_album_ids ?? []);
const ownAlbums = (current.albums ?? [])
.filter((a) => {
if (foreign.has(a?.id)) return false;
if (!a?.appears_on) return true;
if (owned.has(a?.id)) return true;
// No MB albums = no arbiter = keep everything, so thin-coverage
// artists never lose real releases.
return identityAlbums.length ? mbTitles.has(normText(a.title)) : true;
})
// Rows MusicBrainz typed Mixtape/Street under a title Deezer spells
// differently. A new object rather than a mutation: `current.albums`
// is shared with the page replay cache and with whatever is already
// rendered, and this must not edit either in place.
.map((a) =>
adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a,
);
const merged = {
...current,
identity_merged: true,
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
// reason its releases are: it is whichever co-tenant uploaded last.
// Wand's is a different artist entirely, and `deezerPortraitMissing`
// cannot catch it — it is a real photograph, just not of this band.
fused_record: !!identity?.fused_record,
albums: extra.length
? [...ownAlbums, ...extra].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
: ownAlbums,
socials: socials.length ? socials : current.socials,
typeHints: typeHints.length ? typeHints : current.typeHints,
};
return merged;
}
+16
View File
@@ -409,6 +409,9 @@ const enabled = (() => {
})();
/** Returns a Promise for mocked commands, or undefined to pass through. */
/** the playlists the mock keeps offline — the config's list, in memory */
const mockOffline = [];
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
/* the weather, BEFORE any dispatch, so a plugin: command and a catalog
@@ -831,6 +834,19 @@ function mockCatalog(cmd, args) {
record of having posted and cleared */
if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
if (cmd === "plugin:transfer-notice|clear") return Promise.resolve();
/* the car's error report, the resume point, the stream-source mirror
and the offline switch: all fire-and-forget on the page, all answered
so the mock walk never sees a rejection where the phone sees none */
if (cmd === "plugin:media-session|remote_failed") return Promise.resolve();
if (cmd === "playback_note_queue") return Promise.resolve();
if (cmd === "stream_source_remember" || cmd === "stream_source_keep") return Promise.resolve();
if (cmd === "playlist_offline_list") return Promise.resolve(mockOffline.slice());
if (cmd === "playlist_offline_set") {
const i = mockOffline.indexOf(Number(args?.id));
if (args?.keep && i < 0) mockOffline.push(Number(args.id));
if (!args?.keep && i >= 0) mockOffline.splice(i, 1);
return Promise.resolve(mockOffline.slice());
}
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+40
View File
@@ -116,6 +116,45 @@ function rememberStreamSource(item) {
map.set(key, record);
}
saveStreamSources();
/* and the Rust side's copy (stream_sources.rs), so a process with no
page — the media service a car starts — can tell that this cache file
is this playlist row. Fire and forget: a desktop build and the mock
both refuse it, and neither is a reason to forget locally. */
mirrorStreamSources([{ item, record }]);
}
function mirrorStreamSources(pairs) {
const entries = pairs.map(({ item, record }) => ({
track_id: item.trackId != null ? String(item.trackId) : null,
artist: item.artist ?? null,
title: item.title ?? null,
username: record.username,
path: record.path,
size: record.size,
duration: record.duration ?? null,
}));
if (entries.length) api.streamSourceRemember(entries).catch(() => {});
}
/** Once, at boot: push every memory this page holds to the Rust side. The
* store predates the mirror, so a phone that streamed for weeks before it
* existed has cache files the cold browse could not otherwise name. The
* page's keys are `t:<id>` and `n:<artist>|<title>`; the identity is
* recovered from the key, which is all the Rust side keys on too. */
function syncStreamSourcesToBackend() {
const map = loadStreamSources();
const seen = new Map(); // record path → { item, record }
for (const [key, record] of map) {
const k = `${record.username}|${record.path}`;
let e = seen.get(k);
if (!e) { e = { item: { trackId: null, artist: null, title: null }, record }; seen.set(k, e); }
if (key.startsWith("t:")) e.item.trackId = key.slice(2);
else if (key.startsWith("n:")) {
const bar = key.indexOf("|");
if (bar > 2) { e.item.artist = key.slice(2, bar); e.item.title = key.slice(bar + 1); }
}
}
mirrorStreamSources([...seen.values()]);
}
/** The remembered source for this track, but ONLY if its cache file is
@@ -842,6 +881,7 @@ export {
loadStreamSources,
saveStreamSources,
rememberStreamSource,
syncStreamSourcesToBackend,
cachedStreamSourceFor,
passesSourcePrefs,
asciiFold,
+8 -236
View File
@@ -28,6 +28,7 @@ import {
libraryAlbumIn,
libraryTrackIn,
} from "./sources.js";
import { artistIdentityKey, recordBelongsToArtist, artistScopedRecords, mergeArtistIdentity } from "./artistIdentity.js";
export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js";
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
@@ -1434,38 +1435,6 @@ const sameAlbumId = (left, right) => {
return !!leftKey && leftKey === albumCacheKey(right);
};
const artistIdentityKey = (value) => {
const text = typeof value === "string" ? value : value?.name;
return (text ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
};
/** Accept an exact artist credit or an explicit collaboration credit containing
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
* "Crazy Penis"). Missing credit metadata is retained because there is no
* identity signal to evaluate. */
function recordBelongsToArtist(record, artistName) {
const credited = typeof record?.artist === "string"
? record.artist
: record?.artist?.name;
if (!credited) return true;
const expected = artistIdentityKey(artistName);
if (!expected) return true;
if (artistIdentityKey(credited) === expected) return true;
return credited
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
.some((part) => artistIdentityKey(part) === expected);
}
function artistScopedRecords(records, artistName) {
return Array.isArray(records)
? records.filter((record) => recordBelongsToArtist(record, artistName))
: [];
}
/** Sanitize every staged/full artist payload before it enters a shared cache.
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
* second line of defense for additive provider records such as Apple/MB data. */
@@ -2543,210 +2512,6 @@ async function applyArtistIdentity(artistId, active) {
}
}
/** The identity merge itself, as a pure function of the page and the identity
* payload.
*
* Extracted so the Now Playing panel can run the SAME merge the artist page
* runs. It used to live inside `applyArtistIdentity`, which only the artist
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
* appeared with four releases because Loveless and Isn't Anything exist only
* as MusicBrainz supplements, and Weezer filed differently because the panel
* never received `type_hints` either. Both surfaces read one implementation
* now; a second copy would only guarantee they disagree in new ways.
*/
function mergeArtistIdentity(current, identity) {
// MB identity extras are always albums, so only rows that could BE
// that album may block one: a same-title SINGLE is a different
// release (the "Phantom Island" shape — a 2024 single and a 2025
// album), and letting it occupy the title made the real album vanish
// from the page.
const seen = new Set(
(current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => normText(a.title)),
);
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
// An MB row whose title is a PREFIX of an existing row's full title
// is the same work Deezer carries as a qualified variant —
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
// (Deluxe)". Injecting it adds a second card with no track count.
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
// distinct: a bare numeral/volume marker never counts as a variant
// qualifier. normText already strips brackets, so compare against
// the raw title normalized without stripping.
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
// Same single-typed exemption as `seen`: only album-like rows may
// claim to be a variant of an MB album.
const existingRaw = (current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => rawNorm(a.title));
const sequelTail = (tail) =>
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
tail.trim(),
);
const coveredByVariant = (title) => {
const base = rawNorm(title);
if (base.length < 8) return false;
return existingRaw.some(
(t) =>
t.length > base.length
&& t.startsWith(base)
&& !sequelTail(t.slice(base.length)),
);
};
// `normText` DELETES bracket contents, which is right for peer file
// matching and wrong here: MusicBrainz writes a qualifier after a colon
// where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced
// to "worldwar3" while MB's "World War 3: Lean" reduced to
// "worldwar3lean" and the two never collided. The result was a second
// card for a record already on the page — and once mixtapes had a tab
// of their own, the same work appeared in Albums AND Mixtapes.
//
// This key keeps the qualifier and instead folds the three things the
// providers genuinely disagree about: the separator, the words
// introducing a number, and how that number is written. Measured on
// Gucci Mane it collapses ten such pairs, among them
// "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five",
// "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" /
// "Trap House Four".
//
// Single-letter roman numerals are deliberately NOT folded: "X", "V"
// and "I" are real album titles, and reading them as 10, 5 and 1 would
// merge unrelated records.
const ORDINALS = {
one: "1", two: "2", three: "3", four: "4", five: "5",
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9",
};
const releaseKey = (title) =>
asciiFold(title ?? "")
.toLowerCase()
.replace(/[‘’']/g, "")
.replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(" ")
.map((word) => ORDINALS[word] ?? word)
.join("");
// Every album-like row already on the page, by that key, so a matched
// MB row can be recognised as the one on screen rather than injected
// beside it.
// Every row sharing a key, not just the first: a provider sometimes
// carries the same record twice under its own spellings ("Brick Factory
// 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put
// one copy in Mixtapes and leave the other in Albums, which is a worse
// page than leaving both alone.
const existingByKey = new Map();
for (const album of current.albums ?? []) {
if (album.record_type === "single") continue;
const key = releaseKey(album.title);
if (!key) continue;
if (!existingByKey.has(key)) existingByKey.set(key, []);
existingByKey.get(key).push(album);
}
// A MusicBrainz mixtape that IS a row already on the page contributes
// its TYPE, not a card. The Deezer row wins the slot because it is the
// playable one — it carries a real album id and tracklist — and MB's
// Mixtape/Street verdict re-files it, exactly as `type_hints` re-files
// a mislabelled compilation. That is what moves "World War 3 (Lean)"
// out of Albums instead of leaving one copy in each tab.
const adoptedMixtapeIds = new Set();
const extra = identityAlbums.filter((a) => {
if (!a?.title) return false;
const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined;
if (twins?.length) {
for (const twin of twins) {
if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id);
}
return false;
}
return (
!seen.has(normText(a.title))
&& !existingByKey.has(releaseKey(a.title))
&& !coveredByVariant(a.title)
);
});
const socials = identity?.socials ?? [];
// MB's authoritative primary/secondary types. The view matches these
// to displayed releases by title and re-files any the provider
// mislabeled "album" (compilations, soundtracks…).
const typeHints = identity?.type_hints ?? [];
// Guest appearances, dropped only where MusicBrainz can arbitrate.
//
// Deezer's /artist/{id}/albums returns records the artist merely
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
// Thief's discography that way. The backend flags them (`appears_on`:
// the release's primary artist is someone else), but that flag ALONE
// must never delete a row: Deezer marks every contributor "Main", so a
// guest spot looks exactly like a genuine joint album — Kanye West's
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
// erased from his page.
//
// MusicBrainz separates them, because it credits a real collaboration
// under BOTH artists: querying Kanye's own MBID returns "Watch the
// Throne", while Mid-Air Thief's returns only his two real albums. So a
// flagged row survives if MB credits it and is dropped if MB does not
// — and ONLY when MB actually answered for this artist. No MB albums
// means no arbiter, and every row is kept rather than guessed at, which
// keeps thin-coverage artists (the obscure end, where this matters
// most) from losing real releases.
//
// The title check alone is not enough, and Kanye is the proof. MB files
// a collaboration under both parties only when the credit NAMES both
// ("Watch the Throne"); when the collaboration is its own MB entity it
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
// appears anywhere in Ye's 315 release groups. All three were being
// erased from his page by the very rule written to protect them.
// `owned_album_ids` is the backend's wider answer to the same question
// — MB release groups of every type plus the MB-verified Apple
// catalogue — so a real collaboration survives on evidence instead of
// on MusicBrainz happening to duplicate the credit.
//
// `foreign_album_ids` is the other half: releases that belong to a
// DIFFERENT act sharing this Deezer artist record. Deezer files several
// unrelated performers under one id (there is exactly one Deezer
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
// Indonesian act and four more), which no amount of search ranking or
// credit-role reading can undo. The backend only fills this in when it
// can NAME the co-tenant, so on an ordinary artist it is empty and the
// page is untouched.
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
const owned = new Set(identity?.owned_album_ids ?? []);
const foreign = new Set(identity?.foreign_album_ids ?? []);
const ownAlbums = (current.albums ?? [])
.filter((a) => {
if (foreign.has(a?.id)) return false;
if (!a?.appears_on) return true;
if (owned.has(a?.id)) return true;
// No MB albums = no arbiter = keep everything, so thin-coverage
// artists never lose real releases.
return identityAlbums.length ? mbTitles.has(normText(a.title)) : true;
})
// Rows MusicBrainz typed Mixtape/Street under a title Deezer spells
// differently. A new object rather than a mutation: `current.albums`
// is shared with the page replay cache and with whatever is already
// rendered, and this must not edit either in place.
.map((a) =>
adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a,
);
const merged = {
...current,
identity_merged: true,
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
// reason its releases are: it is whichever co-tenant uploaded last.
// Wand's is a different artist entirely, and `deezerPortraitMissing`
// cannot catch it — it is a real photograph, just not of this band.
fused_record: !!identity?.fused_record,
albums: extra.length
? [...ownAlbums, ...extra].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
: ownAlbums,
socials: socials.length ? socials : current.socials,
typeHints: typeHints.length ? typeHints : current.typeHints,
};
return merged;
}
/** Deferred footer links: merged whenever they arrive, never blocking the
* identity pass. Cached back into the session page cache like the identity
* merge, so a warm revisit keeps its links. */
@@ -5324,6 +5089,13 @@ export function initEvents() {
void loadBuddies();
} else if (ev.kind === "disconnected") {
app.connection = "offline";
} else if (ev.kind === "reconnecting") {
// The backend is dialling the dropped session back on its own
// (commands.rs schedule_reconnect, with the server's throttle
// respected). "connecting" rather than "offline", so the login
// modal is not pushed at a session that is about to be back.
app.connection = "connecting";
app.loginError = "";
}
});
+10 -1
View File
@@ -14,6 +14,7 @@
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js";
import { isOffline, setOffline } from "./offline.svelte.js";
import { DOORS, start as startImport, pickImportFolder, startFolder, pickImportFile, startFile } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js";
@@ -482,6 +483,14 @@
{#if kind === "playlist-more" && unres.length}
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
{/if}
{#if kind === "playlist-more" && bulkable(rows)}
<div class="sheet-hair"></div>
{#if isOffline(p.id)}
{@render row("check", "Kept offline", "every streamed track fetched and kept on this phone — tap to stop keeping it", act(() => { void setOffline(p, false); }))}
{:else}
{@render row("download", "Keep offline", "every streamed track fetched in the background and kept, so it plays in the car with the app shut", act(() => { void setOffline(p, true); }))}
{/if}
{/if}
{#if kind === "playlist-more" && bulkable(rows) && !bulk.active}
<div class="sheet-hair"></div>
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
@@ -497,7 +506,7 @@
{@const rows = shown.arg.items || []}
{@const item = shown.arg.item}
<div class="sheet-head">
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
<span class="art">{#if item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
</div>
<div class="sheet-rows">
+19 -2
View File
@@ -37,6 +37,9 @@
import Diagnostics from "./screens/Diagnostics.svelte";
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
import { offline, bindOffline, setOffline, isOffline, cancelOffline } from "./offline.svelte.js";
import { coverart, bindCoverArt } from "./coverart.svelte.js";
import { syncStreamSourcesToBackend } from "../lib/sources.js";
import { home, bindHome, loadHome } from "./home.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import Stub from "./screens/Stub.svelte";
@@ -176,7 +179,12 @@
it — the walk, with nothing fetched — drives the notice exactly as a
real one does. It reads the store and never the notice's own record,
which is why that record is a plain object. */
$effect(() => { syncShade(); });
$effect(() => {
/* the offline worker rides the same notice: read what it shows so a
change of row or of progress re-posts the shade */
void offline.active; void offline.done; void offline.title; void offline.percent;
syncShade();
});
/* ── the first run stamps when it goes away, whatever closed it ────
The foot's button calls finishSetup itself; the scrim, the back
@@ -300,6 +308,15 @@
watchLibrary();
bindTransfers();
bindXferNote();
/* the playlists kept offline, and the stream-source mirror the cold
media service reads: memories made before the mirror existed reach
it once, here, so cache files a car could play are named */
void bindOffline();
/* every streamed row's cover, in the background — the file's own
picture first, then the catalog, persisted on the row so the car
(which reads rows straight out of the database) has it too */
void bindCoverArt();
setTimeout(() => { try { syncStreamSourcesToBackend(); } catch { /* fine */ } }, 3000);
bindHome();
bindImporter();
bindShare();
@@ -323,7 +340,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms, offline, offlineCtl: { setOffline, isOffline, cancelOffline }, coverart };
return () => { if (unlisten) unlisten(); };
});
</script>
+29
View File
@@ -7,6 +7,7 @@
exact-name matches, the most-followed wins, so a shared name resolves to
the canonical artist and never to a namesake the search listed first. */
import * as api from "../lib/api.js";
import { mergeArtistIdentity } from "../lib/artistIdentity.js";
import { note } from "./notice.svelte.js";
export const catalog = $state({
@@ -69,6 +70,7 @@ export function lookupArtist(name, seed = null) {
catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null };
const page = await api.catalogArtist(best.id);
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
void applyIdentity(k, best.id, page?.artist?.name ?? best.name ?? name);
} catch (e) {
/* transport failures stay retryable — the next visit asks again */
catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) };
@@ -76,6 +78,33 @@ export function lookupArtist(name, seed = null) {
})();
}
/** The identity pass — the desktop's applyArtistIdentity, after the page
* is on screen: what MusicBrainz (and the MB-verified Apple catalogue)
* add to the Deezer list — the releases Deezer's artist listing omits
* (EPs, and whole albums: My Bloody Valentine had four releases here
* before this), the authoritative release types, and the arbitration of
* guest appearances and co-tenants — merged by the ONE implementation
* both surfaces read (lib/artistIdentity.js). Best-effort: the page is
* complete without it, and a transient miss is asked once more after
* eight seconds, like the desktop. */
async function applyIdentity(k, id, name) {
const still = () => { const cur = catalog.artists[k]; return cur && cur.id === id && cur.status === "ready" && cur.page ? cur : null; };
try {
let identity = await api.catalogArtistIdentity(id, name);
const empty = !(identity?.albums?.length || identity?.type_hints?.length);
if (empty) {
await new Promise((r) => setTimeout(r, 8000));
if (!still()) return;
identity = await api.catalogArtistIdentity(id, name);
}
const cur = still();
if (!cur) return;
cur.page = mergeArtistIdentity(cur.page, identity);
} catch {
/* best-effort supplement: the page is complete without it */
}
}
/** Fans also like, the Last.fm profile and the bio — the desktop's
* ArtistExtras, asked once per artist after the page is on screen. */
export function loadExtras(id, name) {
+272
View File
@@ -0,0 +1,272 @@
/* Every streamed row gets its cover.
The desktop fills a playlist's blank rows when the page is OPEN
(src/views/Playlist.svelte, backfillArt) and a playing track's when it
PLAYS (state.svelte.js, ensureStreamArt). The phone does both, and one
thing more: a walk over EVERY playlist in the background, so a row is
covered before anyone looks at it — the car's browse tree and playback
card read the rows straight out of the database with no page to fill
them (auto_browse.rs), and thirty of GhettoTechie's forty rows had
nothing there to read.
THE ORDER OF CERTAINTY is the desktop's, kept exactly: the file before
the catalog. A row already streamed once holds the peer's own copy, and
the cover embedded in those bytes belongs to the recording without any
matching at all — for the underground rows this walk exists for, the
file is the only place a cover exists. Only a file that carries none
(or is not here) asks the catalog, and then through the ONE ladder the
import review verifies rows with (catalogMatch.js findCatalogTrack): a
loose name lookup once put one record's cover on seven unrelated songs,
so no match means no art. A blank tile is the honest answer.
WHAT IS WRITTEN. A hit is persisted on the row (playlist_relink_item:
the extracted file as art_path, or the catalog cover as art_url with
the track id), which is what the page, the media card, the resume point
and the car all read. A miss is remembered for a day — in this browser,
not on the row — so a boot does not re-ask the catalog thirty times
about rows it has already said no to; it is remembered only when the
catalog ANSWERED, never when the phone was offline.
PACE. One row at a time, a beat between rows, after boot and again when
a playlist changes (an import adds rows) — never during the first
seconds of a start. The Deezer client paces itself underneath
(metadata crate); this only keeps the walk from being a burst. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { lib } from "./lib.svelte.js";
import { findCatalogTrack, nameNorm } from "../lib/catalogMatch.js";
import { cachedStreamSourceFor, hqCover } from "../lib/sources.js";
export const coverart = $state({
/** a pass is running */
active: false,
/** rows waiting in this pass */
pending: 0,
/** rows covered since boot */
found: 0,
/** rows looked at since boot */
walked: 0,
/** why the last pass stopped early, for the diagnostics screen */
lastError: "",
});
/* ── what is known ────────────────────────────────────────────────── */
/** artist|title → { url, trackId }: covers found this session, by the walk
* or by the player, so the same track twice is one lookup and a row the
* player covered is persisted without another. */
const learned = new Map();
/** rows attempted this session, by item id — once each, like the desktop */
const attempted = new Set();
const MISS_KEY = "ss.coverMisses";
const MISS_TTL_MS = 24 * 3600 * 1000;
let misses = null;
function loadMisses() {
if (misses) return misses;
misses = new Map();
try {
const raw = JSON.parse(localStorage.getItem(MISS_KEY) || "{}");
const now = Date.now();
for (const [k, at] of Object.entries(raw)) if (now - at < MISS_TTL_MS) misses.set(k, at);
} catch { /* nothing remembered */ }
return misses;
}
function saveMisses() {
try { localStorage.setItem(MISS_KEY, JSON.stringify(Object.fromEntries(loadMisses()))); } catch { /* fine */ }
}
export const coverKey = (artist, title) => nameNorm(artist) + "|" + nameNorm(title);
const missedRecently = (key) => loadMisses().has(key);
function noteMiss(key) {
loadMisses().set(key, Date.now());
saveMisses();
}
/** The catalog's cover for a track, strictly, or null. Shared by the walk
* and the player: one definition of "this row is that record", one
* memory of the answer. Rejects only when the catalog could not be
* asked at all (offline), so the caller can stop rather than record a
* miss it never heard. */
export async function catalogCoverFor(artist, title, durationSecs = null) {
if (!artist || !title) return null;
const key = coverKey(artist, title);
if (learned.has(key)) return learned.get(key);
if (missedRecently(key)) return null;
/* findCatalogTrack swallows a failed search into "no match"; the
search is wrapped so the difference survives — a miss is only a
miss when the catalog answered */
let answered = false;
let failed = null;
const search = async (q) => {
try {
const r = await api.catalogSearchTracks(q);
answered = true;
return r;
} catch (e) {
failed = e;
throw e;
}
};
const shelf = async (a, t) => {
try {
const r = await api.catalogArtistTrackCandidates(a, t);
answered = true;
return r;
} catch (e) {
failed = e;
throw e;
}
};
const hit = await findCatalogTrack(search, { title, artist, duration_secs: durationSecs ?? null }, shelf);
if (hit?.cover) {
const found = { url: hqCover(hit.cover) ?? hit.cover, trackId: hit.id != null ? String(hit.id) : null };
learned.set(key, found);
return found;
}
if (!answered && failed) throw failed;
if (answered) noteMiss(key);
return null;
}
/** The player found a cover while playing (or the sheet, or an import):
* remembered, and the walk persists it on every row that is this track. */
export function learnCover(artist, title, url, trackId = null) {
if (!artist || !title || !url) return;
learned.set(coverKey(artist, title), { url, trackId });
schedule(1500);
}
/* ── the rows ───────────────────────────────────────────────────────── */
/** a stream row with nothing to draw */
export const wantsCover = (item) =>
item && item.kind === "stream" && !item.art_url && !item.art_path && !!item.artist && !!item.title;
/** { plId, item }, the open page's rows at the front */
const queue = [];
const listeners = new Set();
/** Hear about a row that was just covered: fn(plId, itemId, fields), where
* fields is what was persisted ({ artPath } or { artUrl, trackId }). The
* open playlist page patches its rows from this rather than re-reading. */
export function onCover(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Cover these rows of this playlist; `first` puts them ahead of the walk
* (the page someone is looking at). Rows already attempted this session
* are left alone. */
export function wantCovers(plId, items, first = false) {
const rows = (items || []).filter((i) => wantsCover(i) && !attempted.has(i.id) && !queue.some((q) => q.item.id === i.id));
if (!rows.length) return 0;
const jobs = rows.map((item) => ({ plId, item }));
if (first) queue.unshift(...jobs);
else queue.push(...jobs);
coverart.pending = queue.length;
kick(first ? 0 : 400);
return rows.length;
}
/** Every playlist's blank rows, queued behind whatever is already waiting. */
export async function walkAll() {
for (const p of lib.playlists || []) {
let rows = [];
try { rows = (await api.playlistItems(p.id)) || []; } catch { continue; }
wantCovers(p.id, rows);
}
}
async function persist(plId, row, fields) {
try {
await api.playlistRelinkItem(plId, row.id, fields);
} catch {
return false; // the row vanished mid-walk — nothing to persist onto
}
coverart.found++;
for (const fn of listeners) {
try { fn(plId, row.id, fields); } catch { /* a page that went away */ }
}
return true;
}
/** One row: the file, then what is known, then the catalog. */
async function fill(plId, row) {
attempted.add(row.id);
coverart.walked++;
/* the file before the catalog */
try {
const src = await cachedStreamSourceFor(row.track_id ?? null, row.artist, row.title);
if (src) {
const embedded = await api.streamEmbeddedArt(src.username, src.path);
if (embedded) return persist(plId, row, { artPath: embedded });
}
} catch { /* no file here, or an unreadable one — the catalog is next */ }
const found = await catalogCoverFor(row.artist, row.title, row.duration_secs ?? null);
if (!found) return false;
return persist(plId, row, {
artUrl: found.url,
trackId: found.trackId && !row.track_id ? found.trackId : null,
});
}
let running = false;
let kickTimer = null;
function kick(delayMs) {
clearTimeout(kickTimer);
kickTimer = setTimeout(() => { void run(); }, delayMs);
}
async function run() {
if (running) return;
running = true;
coverart.active = true;
coverart.lastError = "";
try {
while (queue.length) {
const { plId, item } = queue.shift();
coverart.pending = queue.length;
try {
await fill(plId, item);
} catch (e) {
/* offline, or the catalog unreachable: this row is not a
miss and the rest of the pass can wait — a minute, then
from here */
attempted.delete(item.id);
queue.unshift({ plId, item });
coverart.pending = queue.length;
coverart.lastError = String(e?.message || e);
kick(60_000);
return;
}
await new Promise((r) => setTimeout(r, 300));
}
} finally {
running = false;
coverart.active = false;
}
}
/* ── when ───────────────────────────────────────────────────────────── */
let walkTimer = null;
export function schedule(delayMs) {
clearTimeout(walkTimer);
walkTimer = setTimeout(() => { void walkAll(); }, delayMs);
}
let bound = false;
/** After boot, a while after — the first screen and a restored track come
* first — then whenever a playlist changes, and every six hours for rows
* whose miss has expired or whose file has since arrived. */
export async function bindCoverArt() {
if (bound) return;
bound = true;
schedule(25_000);
try {
await listen("soulseek:playlists-changed", () => schedule(8000));
} catch { /* not a phone */ }
setInterval(() => {
attempted.clear();
schedule(0);
}, 6 * 3600_000);
}
+108 -1
View File
@@ -5,8 +5,11 @@
player carries out. Everything real lives in the plugin
(crates/tauri-plugin-media-session); this is the wiring. */
import { invoke, addPluginListener } from "@tauri-apps/api/core";
import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js";
import { player, hooks, toggle, next, prev, seekSecs, playAlbum, playEntries, entriesFromResume } from "./player.svelte.js";
import { art } from "./art.svelte.js";
import { playFrom } from "./playlists.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { listPlaylists, playlistItems, playbackResumePoint } from "../lib/api.js";
/* THE QUEUE WINDOW. Every update carries a bounded slice of the queue —
the current item and QUEUE_WINDOW either side. Since round forty-eight
@@ -146,11 +149,115 @@ export async function bindMedia() {
case "prev": prev(); break;
case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break;
case "stop": if (playing) toggle(); break;
case "playRemote": playRemote(p.playlist, p.item); break;
case "playMediaId": playMediaId(p.desc, p.startMs); break;
}
});
} catch {
/* not a phone */
}
/**
* A row the CAR tapped that only the app can play.
*
* Android Auto can start this app with no UI, and a service-only process can
* play a local file but not a catalog one — that needs the resolver, which is
* here. So the car hands the row over instead of failing, and this plays it
* exactly the way tapping it in the app would: same `playFrom`, same queue,
* same provenance. Nothing about the car is special once it arrives.
*/
async function playRemote(playlistId, itemId) {
if (!playlistId) return;
/* the car is told WHY when this does not start — until 2026-09-18 a
hand-off that failed here was silence on the head unit, and the
driver could not tell a slow resolver from a dead one */
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
const pls = await listPlaylists();
const pl = (pls || []).find((p) => Number(p.id) === Number(playlistId));
if (!pl) { tell("That playlist is gone."); return; }
const items = (await playlistItems(pl.id)) || [];
/* item 0 means "the playlist", anything else is the row that was
tapped and the rest follows it — which is what playFrom's third
argument means. */
const at = Number(itemId) ? items.find((i) => Number(i.id) === Number(itemId)) : null;
if (!playFrom(pl, items, at)) {
player.error = "Nothing in “" + pl.name + "” can play yet.";
tell(player.error);
return;
}
/* the resolver runs after playFrom returns; its verdict lands on
player.error a few seconds later. Watch for it once, so a "no
source" or "sign in" reaches the car instead of only the phone. */
const started = Date.now();
const watch = setInterval(() => {
if (player.status === "playing") { clearInterval(watch); return; }
if (player.error) { clearInterval(watch); tell(player.error); return; }
if (Date.now() - started > 45_000) clearInterval(watch);
}, 500);
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/**
* ANY row the car tapped while the app is up — the page owns the queue, so
* the tap is the page's whatever the row is. `desc` is the row as Rust
* spelled it for us (auto_browse.rs describe_json): a library record or one
* of its tracks plays the way tapping it in the app would; a playlist row
* takes the hand-off above; "resume" puts back whatever the resume point
* holds, at the position the car asked for or the saved one.
*/
async function playMediaId(descJson, startMs) {
let d = {};
try { d = JSON.parse(descJson || "{}") || {}; } catch { d = {}; }
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
try {
switch (d.kind) {
case "album":
case "track": {
const album = lib.albums.find((a) => Number(a.id) === Number(d.albumId));
if (!album) { tell("That record is not on this phone any more."); return; }
const tracks = (await tracksOf(album.id)) || [];
if (!tracks.length) { tell("That record has no tracks on this phone."); return; }
let at = 0;
if (d.kind === "track") {
const i = tracks.findIndex((t) => Number(t.id) === Number(d.trackId));
if (i >= 0) at = i;
}
await playAlbum(album, tracks, at);
return;
}
case "playlist":
await playRemote(d.playlistId, d.itemId);
return;
case "resume":
await resumeFromPoint(startMs);
return;
default:
tell("SoulStream could not play that.");
}
} catch (e) {
tell(String(e?.message || e || "SoulStream could not play that"));
}
}
/** "Continue" with the app up: the bar's own track if there is one, else
* the resume point the Rust side holds, rebuilt as this page's queue. */
async function resumeFromPoint(startMs) {
const tell = (why) => { invoke("plugin:media-session|remote_failed", { failure: { message: why } }).catch(() => {}); };
if (player.track) {
if (!(player.status === "playing" || player.status === "buffering")) toggle();
return;
}
let rp = null;
try { rp = await playbackResumePoint(); } catch { rp = null; }
if (!rp || !Array.isArray(rp.items) || !rp.items.length) { tell("Nothing to continue yet."); return; }
const entries = entriesFromResume(rp.items);
const idx = Math.min(Math.max(0, rp.idx || 0), entries.length - 1);
const at = typeof startMs === "number" && startMs >= 0 ? startMs / 1000 : (rp.position || 0);
await playEntries(entries, idx, rp.from || "", at > 1 ? at : null);
}
/* the card interpolates its progress from the last update; a top-up
every half minute keeps a long track honest */
setInterval(() => { if (player.track) syncMedia(); }, 30_000);
+259
View File
@@ -0,0 +1,259 @@
/* Keep a playlist offline: every streamed row fetched into the stream cache
in the background and pinned there, so the whole list plays with the app
shut — in a car, in a pocket — the way a downloaded playlist does in any
other player.
WHAT IT IS NOT. Not the library: "Download all tracks" (playlists.svelte.js
runBulk) files every row as a real album in the library, which is Save.
This is the OTHER promise: the playlist stays a playlist of streamed rows,
each one simply already here. The bytes land where a stream's bytes always
land (the stream cache, by the same peer file), the row's memory of which
peer file that is goes to the Rust side too (stream_sources.rs), and the
pruner leaves a pinned file alone however old it gets.
HOW IT RUNS. One pass at a time, one row at a time, in the background of
the page: the remembered peer file first (no search), else the resolver's
ladder over the network — the same source rules a play uses, so what is
kept is what would have played — then the cache fetch the player itself
uses for the next track (preload_stream), polled until the file is whole.
Never two fetches at once and never competing with a search the player is
running: the ladder's own pacing holds, and the row being PLAYED right now
is skipped (a second transfer of the file a peer is already streaming to
us deadlocks both, save_to_library learned that).
WHEN. After boot once the library and the account have answered, again
whenever the account (re)connects, and whenever a playlist changes. Wi-Fi
only when the switch says so (network.svelte.js), like every download.
IN THE SHADE. While it fetches, the downloads notice (xfernote.svelte.js)
carries it — which is also what keeps the process alive once the app is
in the background: without a foreground service Android freezes the
process a minute after the screen leaves it and every fetch stalls until
the app is opened again. Android 12+ only lets that service START from
the foreground, so a pass that begins on screen keeps going in a pocket;
one that begins in the background (a reconnect while the app is behind
another) runs only while the process is awake. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { lib } from "./lib.svelte.js";
import { slsk, awaitOnline } from "./slsk.svelte.js";
import { player } from "./player.svelte.js";
import {
resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, rememberStreamSource,
loadStreamSources, streamSourceKeys, normText, qualityOf,
} from "../lib/sources.js";
import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js";
export const offline = $state({
/** playlist ids kept offline — the config's list, read at boot */
ids: [],
/** a pass is running */
active: false,
/** the playlist and row it is on */
playlistId: null,
title: "",
/** rows checked and rows to check, this pass */
done: 0,
total: 0,
/** the in-flight fetch: 0–100, or -1 while nothing is in flight */
percent: -1,
/** rows this pass could not source */
missed: 0,
/** playlist id → { here, total }: what the header says once a pass has looked */
ready: {},
});
export const isOffline = (id) => offline.ids.some((x) => Number(x) === Number(id));
let cancel = false;
let timer = null;
/** the pass that is running, so a second ask waits for it rather than racing it */
let running = null;
export async function loadOffline() {
try { offline.ids = (await api.playlistOfflineList()) || []; } catch { /* a desktop, or the mock */ }
}
/** Switch a playlist's keep on or off. On: a pass starts at once. Off: the
* rows' pins are lifted so the pruner may reclaim them in its own time;
* nothing is deleted here — a track kept for months does not vanish the
* second the switch flips. */
export async function setOffline(p, keep) {
try { offline.ids = (await api.playlistOfflineSet(p.id, keep)) || []; } catch (e) { player.error = String(e); return; }
if (keep) {
note(`Keeping “${p.name}” offline`);
schedule(0);
} else {
note(`No longer keeping “${p.name}” offline`);
delete offline.ready[p.id];
void unpin(p);
}
}
export function cancelOffline() { cancel = true; }
async function unpin(p) {
let items = [];
try { items = (await api.playlistItems(p.id)) || []; } catch { return; }
for (const r of items) {
if (r.kind !== "stream" || !r.artist || !r.title) continue;
api.streamSourceKeep(r.track_id ?? null, r.artist, r.title, false).catch(() => {});
}
}
/** A pass, soon. Coalesced: a burst of playlist changes is one pass. */
export function schedule(ms = 1500) {
clearTimeout(timer);
timer = setTimeout(() => { void run(); }, ms);
}
/** The remembered peer file for a row, whole or not — the fetch wants the
* identity even when the bytes are not all here yet. */
function rememberedFor(tid, artist, title) {
const map = loadStreamSources();
for (const key of streamSourceKeys(tid, artist, title)) {
const src = map.get(key);
if (src?.username && src.path && src.size > 0) return src;
}
return null;
}
function playingNow(username, path) {
const t = player.track;
return !!t?.stream && t.stream.username === username && t.path === path
&& (player.status === "playing" || player.status === "buffering");
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Wait for a fetch to be whole, reporting its progress; false on a stall
* (no byte for a while) or a cancel. */
async function awaitWhole(src) {
const startedAt = Date.now();
let lastLen = -1;
let lastGrowAt = Date.now();
for (;;) {
if (cancel) return false;
let len = null;
try { len = await api.streamCacheLen(src.username, src.path); } catch { len = null; }
if (len != null && len >= src.size) return true;
if (len != null && len !== lastLen) { lastLen = len; lastGrowAt = Date.now(); }
offline.percent = src.size ? Math.min(99, Math.round(((len || 0) / src.size) * 100)) : -1;
/* a peer that has queued us can take minutes to start; a peer that
started and stopped is not coming back — two different patiences */
const quiet = Date.now() - lastGrowAt;
if ((lastLen > 0 && quiet > 90_000) || (lastLen <= 0 && quiet > 8 * 60_000) || Date.now() - startedAt > 30 * 60_000) return false;
await sleep(3000);
}
}
async function run() {
if (running) return running;
running = (async () => {
cancel = false;
if (!offline.ids.length) return;
if (!downloadsAllowed()) return;
offline.active = true;
offline.done = 0;
offline.total = 0;
offline.missed = 0;
offline.percent = -1;
try {
/* every stream row of every kept playlist, once each */
const work = [];
const seen = new Set();
for (const id of offline.ids) {
const p = lib.playlists.find((x) => Number(x.id) === Number(id));
let items = [];
try { items = (await api.playlistItems(id)) || []; } catch { continue; }
let total = 0;
for (const r of items) {
if (r.kind !== "stream" || !r.artist || !r.title) continue;
total++;
const key = `${normText(r.artist)}|${normText(r.title)}`;
if (seen.has(key)) continue;
seen.add(key);
work.push({ playlistId: Number(id), name: p?.name || "", row: r });
}
offline.ready[id] = { here: offline.ready[id]?.here ?? 0, total };
}
offline.total = work.length;
const here = {};
for (const w of work) {
if (cancel) break;
const { row } = w;
offline.playlistId = w.playlistId;
offline.title = row.title;
offline.percent = -1;
const tid = row.track_id != null && row.track_id !== "" ? Number(row.track_id) : null;
try {
/* already whole: pin it and move on */
const whole = await cachedStreamSourceFor(tid, row.artist, row.title);
if (whole) {
api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {});
here[w.playlistId] = (here[w.playlistId] || 0) + 1;
continue;
}
/* the remembered peer file, else a search of our own */
let src = rememberedFor(tid, row.artist, row.title);
if (!src) {
if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; }
const { responses, artistIncluded } = await resolveSources(
`${row.artist} — ${row.title}`,
[`${row.artist} ${simplifyTitle(row.title)}`, simplifyTitle(row.title)],
(rs) => songCandidates(rs, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist).length,
row.artist,
null,
);
const best = songCandidates(responses, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist, !artistIncluded)[0];
if (!best) { offline.missed++; continue; }
src = { username: best.username, path: best.file.path, size: best.file.size, duration: best.file.duration ?? null, quality: qualityOf(best.file) };
rememberStreamSource({ stream: { username: src.username, size: src.size }, path: src.path, title: row.title, artist: row.artist, trackId: tid, duration: src.duration, quality: src.quality });
}
if (cancel) break;
if (playingNow(src.username, src.path)) { continue; } /* the player is fetching it */
if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; }
try { await api.preloadStream(src.username, src.path, src.size); } catch { offline.missed++; continue; }
if (await awaitWhole(src)) {
api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {});
here[w.playlistId] = (here[w.playlistId] || 0) + 1;
} else {
offline.missed++;
}
} catch {
offline.missed++;
} finally {
offline.done++;
offline.percent = -1;
}
await sleep(500);
}
for (const id of offline.ids) {
if (offline.ready[id]) offline.ready[id] = { ...offline.ready[id], here: here[id] || 0 };
}
} finally {
offline.active = false;
offline.playlistId = null;
offline.title = "";
offline.percent = -1;
}
})().finally(() => { running = null; });
return running;
}
let bound = false;
/** Wire the passes to what should start one. Harmless where nothing can. */
export async function bindOffline() {
if (bound) return;
bound = true;
await loadOffline();
if (offline.ids.length) schedule(12_000);
try {
await listen("soulseek:playlists-changed", () => { if (offline.ids.length) schedule(4000); });
await listen("soulseek:state", (ev) => { if (ev.payload?.kind === "connected" && offline.ids.length) schedule(6000); });
} catch { /* not a phone */ }
/* a long session: look again every half hour, for rows a peer had gone
offline for the last time */
setInterval(() => { if (offline.ids.length && !offline.active) schedule(0); }, 30 * 60_000);
}
+155 -10
View File
@@ -27,7 +27,8 @@
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js";
import { loadArt, art } from "./art.svelte.js";
import { loadArt, art, isStreamKey } from "./art.svelte.js";
import { catalogCoverFor, learnCover } from "./coverart.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { shuffled } from "./util.js";
@@ -237,16 +238,50 @@ function streamStory(g, e) {
} catch { /* nothing to fold yet */ }
if (!artTried && (player.stream.received > 200_000 || done)) {
artTried = true;
const key = e.album?.art_path;
if (key && art[key] === undefined) {
api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; });
}
void ensureStreamArt(g, e, username, path);
}
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
};
streamTimer = setTimeout(tick, 900);
}
/** A streamed track showing no artwork: find it some, in order of certainty
* — the desktop's ensureStreamArt. The bytes the peer is sending ARE the
* recording, so a cover embedded in them belongs to it: no matching, no
* doubt, no network. Only a file that carries none asks the catalog, and
* strictly (coverart.svelte.js: the one ladder, one memory of the answer;
* a loose lookup once put one record's cover on seven unrelated songs).
* A hit becomes the entry's art key — what the bar, the sheet, the media
* card and the resume point all read — and the walk persists it on the
* playlist row. */
async function ensureStreamArt(g, e, username, path) {
const key = e.album?.art_path;
if (!key || !isStreamKey(key)) return; // a cover already: a URL, or a file
if (art[key] === undefined) {
/* the extractor answers with the FILE it wrote, and the art store
holds data URLs — the thumbnail read turns one into the other,
the way a library cover is loaded */
let u = "";
try {
const p = await api.streamEmbeddedArt(username, path);
u = p ? (await api.albumArt(p, 360)) || "" : "";
} catch { u = ""; }
art[key] = u;
}
if (art[key]) return;
let found = null;
try {
found = await catalogCoverFor(e.t.artist, e.t.title, e.t.duration_secs ?? null);
} catch { return; /* offline: a later play asks again */ }
if (!found) return;
e.album.art_path = found.url;
learnCover(e.t.artist, e.t.title, found.url, found.trackId);
void loadArt(found.url, true);
if (g !== gen) return;
mediaSync();
writeSession();
}
/** The index the engine will play next, in this order, with these modes. */
function nextIndex() {
if (!player.queue.length) return -1;
@@ -376,7 +411,7 @@ function advanceGaplessly(i) {
}
/* ── the queue ───────────────────────────────────────────────────────── */
export function playEntries(entries, at = 0, from = "") {
export function playEntries(entries, at = 0, from = "", startSecs = null) {
original = null;
if (player.shuffle && entries.length > 1) {
/* a predetermined order: the chosen track first, the rest shuffled */
@@ -389,8 +424,9 @@ export function playEntries(entries, at = 0, from = "") {
player.qi = at;
player.from = from;
/* the REACTIVE entry, not the raw one: a path-less first entry gets its
stream and path from the resolver, and the bar must see them land */
return startEntry(player.queue[at]);
stream and path from the resolver, and the bar must see them land.
`startSecs` is where a resumed session picks up; null is the top. */
return startEntry(player.queue[at], startSecs);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
@@ -543,6 +579,10 @@ export function seekSecs(s) {
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
api.playerControl("seek", s).catch(setErr);
mediaSync();
/* the resume point too, now: the engine's own flush is every five
seconds, and a phone put down (or a car started) right after a scrub
would otherwise continue from before it (2026-09-18) */
writeSession();
}
export function seekFrac(f) {
if (player.duration) seekSecs(f * player.duration);
@@ -650,9 +690,13 @@ function packEntry(e) {
album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null,
};
}
/* set once restoreSession has READ the saved session, so a write that lands
before it (a visibility change under a launch with the screen off — seen
2026-09-18) cannot remove what it is about to restore */
let sessionBooted = false;
export function writeSession() {
try {
if (!player.track) { localStorage.removeItem(SESSION_KEY); return; }
if (!player.track) { if (sessionBooted) localStorage.removeItem(SESSION_KEY); return; }
localStorage.setItem(SESSION_KEY, JSON.stringify({
v: 1,
queue: player.queue.map(packEntry),
@@ -665,6 +709,70 @@ export function writeSession() {
original: original ? original.map(packEntry) : null,
}));
} catch { /* quota, or storage off: the session is a convenience */ }
noteResume();
}
/* ── the resume point ────────────────────────────────────────────────
The same session, in the Rust side's shape (resume.rs), for everything
that can ask "what was playing?" with no page alive to answer: Android's
own resume card, a head unit's PLAY into a cold app, Android Auto's
autoplay on connection. A bounded window around the current track — the
whole queue is not a thing to write every ten seconds — and each entry
named by what plays it cold: a file on the phone, or the peer file whose
bytes may be in the stream cache. The engine's pump keeps the position;
this names the queue. Fire and forget: the desktop and the mock refuse it
and neither is a reason to fail the session write. */
const RESUME_WINDOW = 25;
function resumeItem(e) {
const t = e?.t || {};
const a = e?.album || {};
const local = !!t.path && !t.stream;
return {
id: t.id != null ? String(t.id) : null,
title: t.title || "",
artist: t.artist || a.artist || "",
album: a.title || "",
duration: t.duration_secs ?? null,
/* a "stream:" key is this page's own name for a cover it fills from
the file's tag; the Rust side reads the tag itself when given none */
art: a.art_path && !String(a.art_path).startsWith("stream:") ? a.art_path : null,
path: local ? t.path : null,
stream: t.stream?.username && t.path ? { username: t.stream.username, path: t.path, size: t.stream.size || 0 } : null,
};
}
/** The resume shape back into queue entries. A cached stream comes back as
* a plain file, which is what it is on this phone; a streamed entry whose
* cache is gone keeps its peer identity so the resolver can find it. */
export function entriesFromResume(items) {
return (items || []).map((it) => {
const local = !!it.path;
return {
t: {
id: it.id ?? it.path ?? it.title,
title: it.title || "",
artist: it.artist || "",
path: local ? it.path : (it.stream?.path || null),
duration_secs: it.duration ?? null,
stream: !local && it.stream?.username ? { username: it.stream.username, size: it.stream.size || 0 } : null,
catalog: null,
alts: [],
},
album: { id: null, catalogId: null, title: it.album || "", artist: it.artist || "", art_path: it.art || null },
};
});
}
function noteResume() {
/* NEVER cleared from here. A page with nothing on its bar is not proof
that there is nothing to continue — it is the state of every boot
before the session is restored, and a visibility change in that
window (the screen going off under a launch) wrote the resume point
away on 2026-09-18. A stale resume point costs a wrong "Continue"; a
wiped one costs the feature. */
if (!player.track || player.qi < 0) return;
const lo = Math.max(0, player.qi - RESUME_WINDOW);
const win = player.queue.slice(lo, player.qi + RESUME_WINDOW + 1);
api.playbackNoteQueue(win.map(resumeItem), player.qi - lo, player.position || 0, player.from || "").catch(() => {});
}
/** Put back what the last launch left behind. The audio thread is the
@@ -673,9 +781,42 @@ export function writeSession() {
export async function restoreSession() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
sessionBooted = true;
let transport = null;
try { transport = await api.playerState(); } catch { transport = null; }
/* A CAR was driving the engine when this page came up (a cold start
from Android Auto, then the app opened over it): the backend hands
that queue over exactly once, and it beats whatever this page's own
storage remembers — restoring an older session over live audio puts
the wrong title on the bar and the wrong record on the ground. The
entries arrive in the resume shape (resume.rs) and are turned back
into queue entries here; a cached stream comes back as a plain file,
which is what it is on this phone. */
const hand = transport?.handover;
if (hand && Array.isArray(hand.items) && hand.items[hand.idx]) {
const entries = entriesFromResume(hand.items);
const g = ++gen;
player.queue = entries;
player.qi = hand.idx;
player.from = hand.from || "";
original = null;
const e = entries[hand.idx];
player.track = e.t;
player.album = e.album;
const live = !!transport && transport.status !== "idle";
player.status = live ? transport.status : "paused";
player.position = live ? (transport.position ?? 0) : (hand.position ?? 0);
player.duration = live && transport.duration != null ? transport.duration : (e.t.duration_secs ?? null);
player.needsResource = !live;
player.phase = "ready";
void loadArt(e.album?.art_path, true);
if (e.t.path) loadWave(g, e.t.path);
if (live) armPreload();
writeSession();
mediaSync();
return true;
}
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
const g = ++gen;
player.queue = saved.queue;
player.qi = saved.qi;
@@ -696,6 +837,10 @@ export async function restoreSession() {
loadWave(g, e.t.path);
if (live) armPreload();
mediaSync();
/* the resume point too: a restored-but-paused session is exactly what a
car connecting after a restart should be able to continue, and until
something played or changed nothing had written it */
noteResume();
return true;
}
+7 -1
View File
@@ -81,11 +81,17 @@ function albumOfItem(item, p) {
const k = String(item.artist || "").toLowerCase();
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
if (own) return own;
/* a streamed row with no catalog cover gets a "stream:" key: the player
fills it from the file's own tag once enough of it is here (thirty of
GhettoTechie's forty rows have no cover URL, and the bar drew nothing) */
/* a stream row's art_path is the cover the walk pulled out of its own
file (coverart.svelte.js), its art_url the catalog's — either draws */
const art = item.art_path ?? item.art_url ?? null;
return {
id: null, catalogId: null,
title: item.album || p?.name || null,
artist: item.artist,
art_path: item.path ? (item.art_path ?? null) : (item.art_url ?? null),
art_path: art ?? (item.kind === "stream" ? "stream:pl" + item.id : null),
};
}
+19 -5
View File
@@ -18,7 +18,7 @@ import {
} from "../lib/sources.js";
import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { slsk, awaitOnline } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
@@ -28,19 +28,33 @@ void refreshSourcePrefs();
const online = () => slsk.connection === "online";
const OFFLINE = "Sign in on Community to play from the network.";
/** Online now — or, while the backend is dialling a dropped session back
* (or the saved account is still signing in at boot), online within a
* moment. A car tapping a streamed row the second the phone thaws must
* not be refused "signed out" by a login that lands three seconds later;
* signed out for real, this answers at once. */
async function ensureOnline() {
if (online()) return true;
if (slsk.connection !== "connecting") return false;
return awaitOnline(20_000);
}
/* ── shapes ────────────────────────────────────────────────────────── */
/** The record a streamed entry belongs to, as the player's `album`. No
* library id: the catalog id rides in `catalogId`, and the cover URL is the
* art key (art.svelte.js routes https through the catalog art proxy). */
function albumMeta(o, artist) {
/* no catalog cover: a "stream:" key, which the player fills from the
file's own tag once enough of it is here — one key per record, so the
first track's picture serves the rest */
const fallback = "stream:cat" + (o.albumId ?? o.trackId ?? artist ?? "");
return {
id: null,
catalogId: o.albumId ?? null,
title: o.album ?? null,
artist: o.albumArtist ?? artist,
year: o.year ?? null,
art_path: hqCover(o.cover) ?? null,
art_path: hqCover(o.cover) ?? fallback,
};
}
function streamEntry(title, artist, src, o, album) {
@@ -119,7 +133,7 @@ export async function streamSong(artist, title, o = {}) {
await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from);
return;
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
placeholder(title, artist, album);
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
@@ -148,7 +162,7 @@ export async function streamCatalogAlbum(album, at = 0) {
const tracks = await tracksOf(local.id).catch(() => []);
if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; }
}
if (!online()) { fail(OFFLINE); return; }
if (!(await ensureOnline())) { fail(OFFLINE); return; }
const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist);
placeholder(album.title, album.artist, meta);
const catalogTracks = album.id
@@ -265,7 +279,7 @@ async function resolveEntry(e, quiet = false) {
e.t.duration_secs = cached.duration ?? e.t.duration_secs;
return true;
}
if (!online()) return false;
if (!(await ensureOnline())) return false;
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
+237 -183
View File
@@ -1,15 +1,25 @@
<script>
/* The artist page, the desktop's (CatalogArtist.svelte + ArtistExtras)
at phone scale: the banner with the wordmark or the name on it, the
identity row — Play popular, Follow, releases, fans, years active,
the genre, the socials — Popular with its listener bars, the latest
release, the discography filed the desktop's way (tabs, editions
folded into families, what the library holds marked), releases by
year, fans also like, what is on this phone, and About.
/* The artist page — Kyle's pick from three redesigns (2026-09-18,
research/mobile-mockup `?look=b`): the editorial shape. A 300dp
portrait with the name ON it and the play disc beside it, one line of
facts, the LATEST RELEASE card first, Top Songs as swipeable columns
of four, then Albums / Singles & EPs / On this phone as carousels,
Similar Artists, and About.
Opens cold: the library's own shell at once (its portrait blurred as
a seed when it is only a cover), the catalog lookup dissolving the
rest in — dissolve, never pop. */
What made the old page "super glitchy" (his word) and what this one
does about it:
- six sections each popped in on their own as their data landed, so
the page reflowed six times — now the cold page renders skeletons
the size of what is coming and swaps once when the catalog lands;
- the hero re-dissolved every time a better picture arrived — now the
first sharp picture is the picture, locked;
- a cloud of stat chips wrapped to three lines — now one line, cut
with an ellipsis;
- the discography tab jumped when the identity merge re-filed
releases — there are no tabs here; the rails just grow.
No bar over the hero: a pushed page floats its two controls (the
shell's .fchrome discs) and the title is the page's own. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
@@ -22,14 +32,14 @@
import { push, openHalf } from "../nav.svelte.js";
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
import { discoTabs, activeYears, latestOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
import { shuffled, mmss, ago, fmtCount } from "../util.js";
/** `seed`: the search's own row (a CatalogArtist), when the page was
* opened from one — the identity is then known, no name search. */
let { name, seed = null, screenEl = null } = $props();
/* the desktop's play-stats chips: this phone's own listening to the artist */
/* this phone's own listening to the artist, for the About paragraph */
let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
$effect(() => {
const n = name;
@@ -38,7 +48,7 @@
api.artistPlayStats(n).then((st) => {
if (plays.key !== n) return;
plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 };
}, () => { /* the chips stay hidden */ });
}, () => { /* the line stays out */ });
});
const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours");
@@ -52,18 +62,11 @@
const entry = $derived(catalog.artists[artistKey(name)] ?? null);
const page = $derived(entry?.page ?? null);
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
/* the two fast lanes the page does not wait for: the banner on its own
(a hero chosen on this phone is read off the file, so it is up before
any network answers) and the one-round content stage. Both sit strictly
BELOW the page everywhere they are read — what the exhaustive pass
returns always wins. */
const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null);
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name);
const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);
const logoUrl = $derived(page?.logos?.[0] ?? null);
/* once per name — untracked, or the entry the lookup writes would re-arm
this effect, and a lookup that fails would fail forever, hot */
$effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); });
$effect(() => { if (seedPath) wantArt(seedPath); });
$effect(() => { if (heroUrl) wantArt(heroUrl); });
@@ -72,21 +75,24 @@
const nm = page?.artist?.name || name;
if (id != null) untrack(() => loadExtras(id, nm));
});
/* three lanes off the same id, all of them ahead of the page: Popular's
ranked rows, the banner on its own, and the first page of releases.
Asked the moment an id is known — from the search's own row, or from
the identity search — so the page can paint before it is built */
$effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); });
const seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
/* the copy behind the scroller, which is the one allowed under the clock */
$effect(() => { heroArt.src = heroSrc || seedSrc || ""; return () => { heroArt.src = ""; }; });
/* the first sharp picture is THE picture. It used to follow `heroSrc`,
and every better banner that arrived — the seed, the portrait, the
page's own — dissolved in over the last one: three dissolves on one
visit read as the page flickering. */
let heroShown = $state("");
$effect(() => { const s = heroSrc; if (s && !heroShown) heroShown = s; });
$effect(() => { heroArt.src = heroShown || seedSrc || ""; return () => { heroArt.src = ""; }; });
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
/* nothing from the catalog yet: the shell, with skeletons where the
sections will be, and one swap when it lands */
const loading = $derived(entry?.status === "loading" && !page);
/* the library's rows for this artist, by loose title, so Popular and the
discography can say what is already here */
let libByTitle = $state(new Map());
const libByTitle = $state(new Map());
let libMap = $state(new Map());
$effect(() => {
const as = albums;
let live = true;
@@ -97,56 +103,56 @@
try { tr = await tracksOf(a.id); } catch { tr = []; }
for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t });
}
if (live) libByTitle = m;
if (live) libMap = m;
})();
return () => { live = false; };
});
void libByTitle;
/* ── Popular ─────────────────────────────────────────────────────── */
/* the page's own list once it lands (the same Last.fm order, from the
same inputs); the fast lane's until then, and for an artist whose
page came back with no Popular at all */
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);
let showAllPopular = $state(false);
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null })));
const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
const share = (t) => (popPeak ? Math.max(0.04, (t.listeners || 0) / popPeak) : 0);
/* ── the discography, filed ──────────────────────────────────────── */
/* the overview's raw first page holds the discography up until the full
page lands with the classified one — never the other way round: the
page's list is a strict upgrade, folded and filed */
/* ── the facts line ──────────────────────────────────────────────── */
const fanCount = $derived(page?.artist?.fans || overview?.artist?.fans || seed?.fans || 0);
const discoAll = $derived(page?.albums ?? overview?.albums ?? []);
const tabs = $derived(discoTabs(discoAll));
let tab = $state("album");
let tabFor = null;
$effect(() => {
const id = artistId;
const ts = tabs;
if (!ts.length) return;
untrack(() => {
const albumsFirst = () => ts.find((t) => t.key === "album")?.key ?? ts[0].key;
if (tabFor !== id) { tabFor = id; tab = albumsFirst(); }
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst();
});
const years = $derived(activeYears(discoAll));
const facts = $derived([fans(fanCount), years, page?.top_genre].filter(Boolean).join(" · "));
/* ── Top Songs ───────────────────────────────────────────────────── */
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
const popularAll = $derived((page?.popular_tracks?.length ? page.popular_tracks : fastPopular).map((t) => ({ t, hit: libMap.get(looseTitle(t.title)) || null })));
let showAllPopular = $state(false);
const PAGE = 4;
const topPages = $derived.by(() => {
const top = popularAll.slice(0, 12);
const out = [];
for (let i = 0; i < top.length; i += PAGE) out.push(top.slice(i, i + PAGE));
return out;
});
/* the grid is a SLICE — the whole tab lives on the discography page,
which the head's "All" and the link under the grid both open. Before
this an artist with 140 releases rendered 140 cards here. */
const GRID_CAP = 12;
const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null);
const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP));
const tabCount = $derived(activeTab?.items.length ?? 0);
let pageIdx = $state(0);
let pagesEl = $state(null);
function onPages() {
const el = pagesEl;
const col = el?.firstElementChild;
if (!el || !col) return;
pageIdx = Math.max(0, Math.min(topPages.length - 1, Math.round(el.scrollLeft / (col.offsetWidth + 12))));
}
/* ── the discography, as rails ───────────────────────────────────── */
const tabs = $derived(discoTabs(discoAll));
const RAIL_CAP = 12;
const albumFamilies = $derived((tabs.find((t) => t.key === "album")?.items ?? []).slice(0, RAIL_CAP));
const shortTabs = $derived(tabs.filter((t) => t.key === "ep" || t.key === "single"));
const shortFamilies = $derived(
shortTabs.flatMap((t) => t.items.map((f) => ({ f, kind: t.key === "ep" ? "EP" : "Single" })))
.sort((a, b) => String(b.f.release_date ?? "").localeCompare(String(a.f.release_date ?? "")))
.slice(0, RAIL_CAP),
);
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
const latest = $derived(latestOf(discoAll));
const timeline = $derived(timelineOf(discoAll));
const years = $derived(activeYears(discoAll));
const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length);
const latestKind = $derived(latest ? ((latest.record_type || "album").toLowerCase() === "single" ? "Single" : (latest.record_type || "album").toLowerCase() === "ep" ? "EP" : "Album") : "");
/* ── the identity row ────────────────────────────────────────────── */
/* ── identity ────────────────────────────────────────────────────── */
const followed = $derived(isFollowed(shownName));
let followBusy = $state(false);
async function follow() {
@@ -155,11 +161,13 @@
try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); }
followBusy = false;
}
/** The opener plugin (an Android intent); the desktop command as the
* fallback for a build without it. */
function openLink(url) {
openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); }));
}
const SOCIAL_ICON = {
bandcamp: "brand-bandcamp", instagram: "brand-instagram", youtube: "brand-youtube", twitter: "brand-x", x: "brand-x",
facebook: "brand-facebook", soundcloud: "brand-soundcloud", tiktok: "brand-tiktok", website: "globe",
};
/* ── playing ─────────────────────────────────────────────────────── */
const current = (t) => player.track?.id === "cat:" + t.id;
@@ -176,7 +184,11 @@
});
} catch (e) { player.error = String(e); }
}
function playTop() { const p = popular[0]; if (p) playPopular(p); }
async function playTop() {
const p = popularAll[0];
if (p) { await playPopular(p); return; }
if (albums.length) { try { await playAlbum(albums[0], await tracksOf(albums[0].id), 0); } catch (e) { player.error = String(e); } }
}
function morePopular(p) {
if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; }
const t = p.t;
@@ -192,11 +204,16 @@
await streamCatalogAlbum({ ...a, artist: shownName }, 0);
} catch (e) { player.error = String(e); }
}
async function shuffleAll() {
/** Shuffle: the records on this phone when there are any; a random
* popular song otherwise, so the button always does something. */
async function shuffle() {
try {
const es = [];
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
if (es.length) await playEntries(shuffled(es), 0, shownName);
if (albums.length) {
const es = [];
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
if (es.length) { await playEntries(shuffled(es), 0, shownName); return; }
}
if (popularAll.length) await playPopular(popularAll[Math.floor(Math.random() * Math.min(popularAll.length, 10))]);
} catch (e) { player.error = String(e); }
}
function openRecord(a) {
@@ -211,132 +228,170 @@
}
function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); }
let bioOpen = $state(false);
const empty = $derived(!albums.length && !popularAll.length && !tabs.length && !loading);
</script>
<div class="hero">
<div class="hero ar">
<div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div>
{#if heroSrc}
<div class="hero-img dissolve" style:background-image={`url("${heroSrc}")`}></div>
{#if heroShown}
<div class="hero-img dissolve" style:background-image={`url("${heroShown}")`}></div>
{/if}
<div class="hero-scrim"></div>
<div class="hero-in">
{#if logoUrl}
<img class="hero-logo" src={logoUrl} alt={shownName} />
{:else}
<div class="hero-name">{shownName}</div>
{/if}
<div class="hero-in row2">
<div class="hero-id">
<div class="ar-kick">Artist</div>
{#if logoUrl}
<img class="hero-logo" src={logoUrl} alt={shownName} />
{:else}
<h1 class="hero-name">{shownName}</h1>
{/if}
</div>
<button class="disc" onclick={playTop} disabled={!popularAll.length && !albums.length} aria-label="Play {shownName}"><Icon name="play" size={22} /></button>
</div>
</div>
<div class="hero-acts">
<button class="disc lg" onclick={playTop} disabled={!popular.length} aria-label="Play popular songs"><Icon name="play" size={26} /></button>
<div class="stats">
<button class="s fb" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
</button>
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
{#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}</span>{/if}
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
{#each socials as link (link.kind + link.url)}
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
{/each}
</div>
<div class="ar-acts">
<button class="btn btn-ghost" onclick={shuffle} disabled={!albums.length && !popularAll.length}><Icon name="shuffle" size={16} />Shuffle</button>
<button class="btn btn-line" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
<Icon name={followed ? "check" : "plus"} size={15} />{followed ? "Following" : "Follow"}
</button>
{#if facts}<span class="ar-facts">{facts}</span>{/if}
</div>
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 12px">Looking up the rest on the catalog…</div>
{:else if entry?.status === "error"}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if popular.length}
<div class="sec"><h2>Popular</h2></div>
{#each popular as p, i (p.t.id)}
<button class="row pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="rank">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album}{p.hit ? " · in your library" : ""}{current(p.t) && resolver.active ? " · finding the best source…" : ""}</span>
{#if popPeak}<span class="lbar"><i style="transform: scaleX({share(p.t).toFixed(3)})"></i><em>{fmtCount(p.t.listeners)}</em></span>{/if}
</span>
<span class="r">{mmss(p.t.duration_secs)}</span>
</button>
{/each}
{#if popularAll.length > 5}
<button class="more-link" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Show less" : "Show more"}</button>
{/if}
{/if}
{#if latest}
<div class="sec"><h2>Latest release</h2></div>
<div class="feat" style="margin: 0 var(--pad)">
<div class="feat-bg" style:background-image={latest.cover ? `url("${latest.cover}")` : null}></div>
<div class="feat-in">
<button class="feat-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
</button>
<div class="feat-copy">
<div class="k">Latest · {(latest.record_type || "album").toUpperCase()}</div>
<div class="feat-name">{latest.title}</div>
<div class="m">{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}</div>
<div class="feat-acts">
<button class="btn btn-fill" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => openRecord(latest)}>Open</button>
</div>
<div class="ar-latest">
<button class="ar-latest-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
</button>
<div class="ar-latest-copy">
<div class="k">Latest release{yearOf(latest.release_date) ? ` · ${yearOf(latest.release_date)}` : ""}</div>
<div class="ar-latest-t">{latest.title}</div>
<div class="m">{latestKind}{latest.track_count ? ` · ${latest.track_count} ${latest.track_count === 1 ? "song" : "songs"}` : ""}{inLib(latest) ? " · on this phone" : ""}</div>
<div class="ar-latest-acts">
<button class="btn btn-fill sm" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost sm" onclick={() => openRecord(latest)}>Open</button>
</div>
</div>
</div>
{:else if loading}
<div class="ar-latest skel-card" aria-hidden="true">
<span class="ar-latest-art skel"></span>
<div class="ar-latest-copy"><span class="skel ln" style="width: 42%; height: 9px"></span><span class="skel ln" style="width: 70%; margin-top: 9px"></span><span class="skel ln" style="width: 50%; height: 9px; margin-top: 8px"></span></div>
</div>
{/if}
{#if tabs.length}
{#if popularAll.length}
<div class="sec">
<h2>Discography <em>· {releasedCount} on the catalog</em></h2>
<button class="all" onclick={() => push("disco", { name }, screenEl)} aria-label="All releases">All<Icon name="chevron-right" size={14} /></button>
<h2>Top Songs</h2>
{#if popularAll.length > PAGE}<button class="all" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Less" : "See All"}<Icon name="chevron-right" size={14} /></button>{/if}
</div>
{#if tabs.length > 1}
<div class="segtabs disco-tabs" role="tablist" aria-label="Discography">
{#each tabs as t (t.key)}
<button class:on={tab === t.key} onclick={() => (tab = t.key)} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button>
{#if showAllPopular}
{#each popularAll as p, i (p.t.id)}
<button class="row ar-pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="ar-n">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : (p.t.album || "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
</span>
<span class="r"><span class="ar-dur">{mmss(p.t.duration_secs)}</span></span>
</button>
{/each}
{:else}
<div class="ar-pages" bind:this={pagesEl} onscroll={onPages}>
{#each topPages as col, ci (ci)}
<div class="ar-col">
{#each col as p (p.t.id)}
<button class="row ar-pop tight" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album || (fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
</span>
<span class="r" role="button" tabindex="0" aria-label="More for {p.t.title}" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); morePopular(p); }}><Icon name="more" size={18} /></span>
</button>
{/each}
</div>
{/each}
</div>
{#if topPages.length > 1}
<div class="ar-dots" aria-hidden="true">{#each topPages as _, i (i)}<i class:on={i === pageIdx}></i>{/each}</div>
{/if}
{/if}
<div class="grid2">
{#each families as f (familyKeyOf(f))}
{:else if loading}
<div class="sec"><h2>Top Songs</h2></div>
<div class="ar-pages" aria-hidden="true"><div class="ar-col">
{#each [58, 44, 66, 50] as w (w)}
<div class="skel-row"><span class="skel art"></span><span class="b"><span class="skel ln" style="width: {w}%"></span><span class="skel ln" style="width: {w - 14}%; height: 9px; margin-top: 7px"></span></span></div>
{/each}
</div></div>
{/if}
{#if albumFamilies.length}
<div class="sec">
<h2>Albums <em>· {tabs.find((t) => t.key === "album")?.items.length ?? albumFamilies.length}</em></h2>
<button class="all" onclick={() => push("disco", { name, tab: "album" }, screenEl)} aria-label="All albums">See All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail ar-rail">
{#each albumFamilies as f (familyKeyOf(f))}
{@const l = familyLib(f)}
<Release
title={f.title}
sub={tab === "upcoming" ? "Upcoming" : [yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
sub={[yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
url={f.cover}
path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "on this phone" : null)}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{#if tabCount > GRID_CAP}
<button class="more-link dg-all" onclick={() => push("disco", { name, tab }, screenEl)}>Show all {tabCount} {activeTab?.label ?? "releases"}</button>
{/if}
{:else if loading}
<div class="sec"><h2>Albums</h2></div>
<div class="rail ar-rail" aria-hidden="true">
{#each [72, 58, 80] as w (w)}
<div class="release skel-card"><span class="cover-wrap skel"></span><span class="skel ln" style="width: {w}%; margin-top: 10px"></span><span class="skel ln" style="width: {w - 30}%; height: 9px; margin-top: 7px"></span></div>
{/each}
</div>
{/if}
{#if timeline}
<div class="sec"><h2>Releases by year <em>· {timeline.last - timeline.first + 1} years · {timeline.total} releases</em></h2></div>
<div class="tl">
{#each timeline.cols as c (c.year)}
<div class="tl-col" class:empty={!c.n} title="{c.year} · {c.n ? c.n + (c.n === 1 ? ' release' : ' releases') : 'nothing released'}">
<span class="tl-bar" style="height:{c.h}px"></span>
<span class="tl-yr">{String(c.year).slice(2)}</span>
</div>
{#if shortFamilies.length}
<div class="sec">
<h2>Singles &amp; EPs <em>· {shortTabs.reduce((n, t) => n + t.items.length, 0)}</em></h2>
<button class="all" onclick={() => push("disco", { name, tab: shortTabs[0].key }, screenEl)} aria-label="All singles and EPs">See All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail ar-rail sm">
{#each shortFamilies as { f, kind } (familyKeyOf(f))}
{@const l = familyLib(f)}
<Release
title={f.title}
sub={[yearOf(f.release_date), kind].filter(Boolean).join(" · ")}
url={f.cover}
path={l?.art_path ?? null}
badge={l ? "on this phone" : null}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{/if}
{#if tabs.length && releasedCount > albumFamilies.length + shortFamilies.length}
<button class="more-link ar-disco" onclick={() => push("disco", { name }, screenEl)}>The full discography · {releasedCount} releases<Icon name="chevron-right" size={14} /></button>
{/if}
{#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="rail ar-rail sm">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each}
</div>
{/if}
{#if extras?.related?.length}
<div class="sec"><h2>Fans also like</h2></div>
<div class="sec"><h2>Similar Artists</h2></div>
<div class="rail">
{#each extras.related.slice(0, 12) as a (a.id)}
<button class="acard" onclick={() => openRelated(a)}>
@@ -348,35 +403,34 @@
</div>
{/if}
{#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="grid2">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each}
</div>
{/if}
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length}
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length || socials.length || playTime}
<div class="sec"><h2>About</h2></div>
<div class="about">
{#if extras.profile?.listeners || extras.profile?.plays}
<div class="stats">
{#if extras.profile.listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(extras.profile.listeners)} listeners</span>{/if}
{#if extras.profile.plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(extras.profile.plays)} plays</span>{/if}
<div class="ar-about">
{#if extras?.profile?.listeners || extras?.profile?.plays || playTime}
<div class="ar-facts wide">
{[extras?.profile?.listeners ? `${fmtCount(extras.profile.listeners)} listeners` : "", extras?.profile?.plays ? `${fmtCount(extras.profile.plays)} plays` : "", playTime ? `${playTime} played here` : ""].filter(Boolean).join(" · ")}
</div>
{/if}
{#if extras.bio}
<p class="bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
{#if extras?.bio}
<p class="ar-bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
{/if}
{#if extras.profile?.tags?.length}
<div class="tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
{#if extras?.profile?.tags?.length}
<div class="chips ar-tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
{/if}
{#if socials.length}
<div class="ar-socials">
{#each socials as link (link.kind + link.url)}
<button class="iconbtn muted sm" onclick={() => openLink(link.url)} aria-label={SOCIAL_LABEL[link.kind] || link.kind} title={SOCIAL_LABEL[link.kind] || link.kind}><Icon name={SOCIAL_ICON[link.kind] || "link"} size={18} /></button>
{/each}
</div>
{/if}
</div>
{/if}
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"}
{#if entry?.status === "error" && !albums.length && !popularAll.length}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if empty}
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div>
{/if}
+26 -2
View File
@@ -14,6 +14,8 @@
import { player } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js";
import { offline, isOffline, cancelOffline } from "../offline.svelte.js";
import { wantCovers, onCover } from "../coverart.svelte.js";
import { say } from "../trouble.js";
import { mmss, longDur, stamp } from "../util.js";
import { reorder } from "../reorder.js";
@@ -40,10 +42,20 @@
if (pls.dragging) return;
const g = ++gen;
api.playlistItems(p.id).then(
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } },
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; wantCovers(p.id, items, true); } },
(e) => { if (g === gen) { loaded = true; loadErr = String(e); } },
);
});
/* a row the cover walk (coverart.svelte.js) filled while this page is
open — the file's own picture or the catalog's — patched in place */
$effect(() => onCover((plId, itemId, f) => {
if (plId !== pl?.id) return;
const row = items.find((i) => i.id === itemId);
if (!row) return;
if (f.artPath) row.art_path = f.artPath;
if (f.artUrl) row.art_url = f.artUrl;
if (f.trackId && !row.track_id) row.track_id = f.trackId;
}));
/* the bar's sheet (playlist-more) reads the open page's rows here */
$effect(() => {
const p = pl;
@@ -126,6 +138,18 @@
<button class="disc lg" onclick={() => playFrom(pl, items)} disabled={!playable} aria-label="Play"><Icon name="play" size={26} /></button>
<button class="iconbtn muted" onclick={() => openHalf("playlist-more", { playlist: pl, items })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
{#if isOffline(pl.id)}
<div class="bulkline">
{#if offline.active && offline.playlistId === pl.id}
<span class="ring"></span>
<span>Keeping offline — {offline.done}/{offline.total}{offline.title ? ` · ${offline.title}` : ""}{offline.percent >= 0 ? ` · ${offline.percent}%` : ""}</span>
<button class="bulkx" onclick={cancelOffline} aria-label="Stop"><Icon name="x" size={14} /></button>
{:else}
<Icon name="check" size={14} />
<span>Kept offline{offline.ready[pl.id] ? ` · ${offline.ready[pl.id].here} of ${offline.ready[pl.id].total} here` : ""}</span>
{/if}
</div>
{/if}
{#if bulk.playlistId === pl.id && (bulk.active || bulk.summary)}
<div class="bulkline">
{#if bulk.active}
@@ -165,7 +189,7 @@
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
<span class="n" class:stamped={isSetlist(pl) && stamped}>{#if isSetlist(pl) && stamped}<span class="stamp">{stamp(item.start_ms)}</span>{:else}{i + 1}{/if}</span>
<span class="thumb">
{#if item.path && item.art_path}<Cover path={item.art_path} size={14} />
{#if item.art_path}<Cover path={item.art_path} size={14} />
{:else if item.art_url}<img src={item.art_url} alt="" loading="lazy" />
{:else}<Icon name="music" size={14} />{/if}
</span>
+88 -13
View File
@@ -120,10 +120,13 @@
display: grid;
place-items: center;
color: #fff;
background: rgba(0, 0, 0, 0.46);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
/* bare glyphs with a shadow, Kyle's pick over the dark discs
(2026-09-18, the artist page's hero): the picture stays whole under
them, and a shadow is what keeps them readable on a bright one */
background: transparent;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.9)) drop-shadow(0 2px 8px rgba(0, 0, 0, 0.55));
}
.fchrome:active { background: rgba(0, 0, 0, 0.62); }
.fchrome:active { background: rgba(255, 255, 255, 0.14); }
.fchrome.back { left: calc(10px + var(--inset-left)); }
.fchrome.more { right: calc(10px + var(--inset-right)); color: rgba(255, 255, 255, 0.82); }
@@ -555,7 +558,7 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
/* ── the artist hero ──────────────────────────────────────────────────────
The desktop hero is a wide banner beside the identity block. A phone has
no beside, so the portrait goes full-bleed and the name sits ON it. */
.hero { position: relative; height: 258px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
.hero { position: relative; height: 300px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 24%; transform: scale(1.04); }
/* the blurred SEED: the picture the library already has, soft and
oversized, until a sharp one exists */
@@ -568,18 +571,18 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
}
/* The hero's picture again, behind the scroller and outside its mask, so the
photograph is allowed under the clock while the words are not. Its geometry
has to match .hero/.hero-img exactly — 258px tall, the same 24% focal point,
has to match .hero/.hero-img exactly — 300px tall, the same 24% focal point,
the same 1.04 scale — or the seam shows. */
.hero-behind {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 258px;
height: 300px;
z-index: 0;
pointer-events: none;
/* the clip .hero gives its own picture; without it the 1.04 below renders
268px in a 258px box and five pixels of photograph appear under the
312px in a 300px box and twelve pixels of photograph appear under the
hero's foot, below the black the scrim fades to */
overflow: hidden;
transform: translateY(var(--hero-shift, 0px));
@@ -1039,17 +1042,11 @@ html[data-material="solid"] .halfsheet { background: #1c1c20; backdrop-filter: n
release on the app's featured surface (the artwork blurred behind itself);
releases by year; About. */
.hero-logo { display: block; max-width: 78%; max-height: 96px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.6)); }
.hero-acts { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 0; }
.hero-acts .stats { flex: 1; min-width: 0; }
.stats button.s { cursor: pointer; }
.stats .s.fb { background: rgba(255, 255, 255, 0.11); color: var(--text); }
.stats .s.fb.on { background: var(--accent); color: #04121a; }
.stats .s.fb:disabled { opacity: 0.5; }
.stats .s.quiet { color: var(--text-secondary); }
.row.pop .rank { width: 16px; text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; }
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
.more-link { display: block; padding: 8px var(--pad) 2px; font-size: 12.5px; font-weight: 750; color: var(--accent-hover); }
.feat { position: relative; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); }
.feat-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(28px) saturate(1.2); opacity: 0.55; }
@@ -1683,3 +1680,81 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
}
.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); }
.dg-eds .ed { width: 100%; }
/* ── the artist page, B (2026-09-18) ──────────────────────────────────────
The editorial shape Kyle picked from three mockups: the name ON the
300dp portrait with the play disc beside it, one line of facts, the
latest release first, Top Songs as swipeable columns of four, the
records as carousels, About last. The bar over the hero is the shell's
two floating discs; nothing else. */
.hero.ar .hero-in { padding-bottom: 14px; }
.hero-in.row2 { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.hero-in.row2 .hero-id { min-width: 0; flex: 1; }
.hero-in.row2 .disc { flex: none; margin-bottom: 2px; }
.hero.ar .hero-name { margin: 0; overflow-wrap: anywhere; }
.ar-kick { font-size: 11px; font-weight: 800; letter-spacing: 0.9px; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); margin-bottom: 5px; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); }
.ar-acts { display: flex; align-items: center; gap: 10px; padding: 14px var(--pad) 6px; }
.ar-facts { margin-left: auto; min-width: 0; font-size: 12.5px; font-weight: 600; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ar-facts.wide { margin: 0 0 8px; white-space: normal; }
.btn-line { box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.22); color: #fff; background: transparent; }
.btn-line.on { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent-hover); }
.btn-line:disabled { opacity: 0.5; }
.btn.sm { height: 34px; padding: 0 14px; font-size: 13px; }
/* the latest release */
.ar-latest { display: flex; gap: 14px; margin: 6px var(--pad) 4px; padding: 12px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); }
.ar-latest-art { flex: none; width: 96px; height: 96px; border-radius: 10px; overflow: hidden; background: rgba(255, 255, 255, 0.06); display: block; padding: 0; }
.ar-latest-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ar-latest-art .ph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--text-muted); }
.ar-latest-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.ar-latest-copy .k { font-size: 10.5px; letter-spacing: 0.8px; text-transform: uppercase; font-weight: 800; color: var(--accent-hover); }
.ar-latest-t { font-size: 15px; font-weight: 800; letter-spacing: -0.2px; line-height: 1.2; margin: 3px 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ar-latest-copy .m { font-size: 12px; color: var(--text-secondary); font-weight: 600; }
.ar-latest-acts { display: flex; gap: 8px; margin-top: 10px; }
/* top songs, in columns of four */
.row.ar-pop .ar-n { width: 18px; text-align: right; color: var(--text-muted); font-weight: 700; font-variant-numeric: tabular-nums; font-size: 13px; flex: none; }
.row.ar-pop .r { display: inline-flex; align-items: center; gap: 10px; color: var(--text-muted); }
.row.ar-pop .ar-dur { font-variant-numeric: tabular-nums; font-size: 12.5px; }
.ar-pages { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 0 var(--pad); scrollbar-width: none; scroll-padding-inline: var(--pad); }
.ar-pages::-webkit-scrollbar { display: none; }
.ar-col { flex: none; width: 86%; scroll-snap-align: start; }
.ar-col .row { margin: 0 calc(-1 * var(--pad)); }
.ar-col .row.tight { padding-top: 6px; padding-bottom: 6px; }
.ar-dots { display: flex; justify-content: center; gap: 6px; padding: 6px 0 2px; }
.ar-dots i { width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.22); transition: background var(--m-fast); }
.ar-dots i.on { background: #fff; }
/* the carousels */
.rail { scroll-padding-inline: var(--pad); }
.ar-rail .release { width: 156px; }
.ar-rail.sm .release { width: 132px; }
.more-link.ar-disco { display: inline-flex; align-items: center; gap: 4px; padding: 14px var(--pad) 4px; }
/* about */
.ar-about { padding: 0 var(--pad); }
.ar-bio { font-size: 13.5px; line-height: 1.6; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; margin: 0; }
.ar-bio.open { display: block; }
.ar-tags { padding: 0; margin-top: 12px; }
.ar-socials { display: flex; gap: 6px; margin-top: 12px; }
.ar-socials .iconbtn { background: rgba(255, 255, 255, 0.055); }
/* skeletons: the shape of what is coming, so the page never jumps when it
arrives (the mockup's .skel, at the app's radii) */
.skel { display: block; background: rgba(255, 255, 255, 0.07); border-radius: 6px; animation: skel-breathe 1.4s ease-in-out infinite; }
.skel.ln { height: 11px; }
.skel.art { width: 46px; height: 46px; border-radius: 8px; flex: none; }
.skel.cover-wrap { aspect-ratio: 1; width: 100%; border-radius: var(--r-art); }
.skel-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.skel-row .b { flex: 1; min-width: 0; }
.skel-card { pointer-events: none; }
.ar-latest.skel-card .ar-latest-art { background: rgba(255, 255, 255, 0.07); }
@keyframes skel-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
/* the listener / progress bars other screens draw under a row (CatalogAlbum,
Transfers, Diagnostics) — the artist page's Popular used them too, once */
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
/* a rail's cards line up at the TOP — a two-line title must not lift its cover */
.ar-rail { align-items: flex-start; }
+26
View File
@@ -34,8 +34,26 @@ export const slsk = $state({
* proved at 1.2 s and the shipped 12 s is a judgement, not a measurement. */
roomsWaitMs: 12000,
roomsLate: false,
/** the backend's automatic redial after a drop: { attempt, inSecs },
* null while online or while nobody is dialling */
redial: null,
});
/** Resolve true as soon as the hub is online, or false after `ms`. Polled
* rather than effected: this is asked from plain async code (the resolver)
* and a quarter second of latency on a twenty-second wait is nothing. The
* point is a car tapping a streamed row the moment the phone comes back
* from a pocket — the backend is already redialling, and refusing the row
* "signed out" three seconds before the login lands is the wrong answer. */
export async function awaitOnline(ms) {
const until = Date.now() + Math.max(0, ms);
while (Date.now() < until) {
if (slsk.connection === "online") return true;
await new Promise((r) => setTimeout(r, 250));
}
return slsk.connection === "online";
}
const FEED_MAX = 300;
function trim(arr) {
if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX);
@@ -159,10 +177,18 @@ export async function bindSlsk() {
slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser;
slsk.loginError = "";
slsk.redial = null;
void loadBuddies();
refreshRooms();
} else if (ev?.kind === "disconnected") {
slsk.connection = "offline";
} else if (ev?.kind === "reconnecting") {
/* the backend is dialling the dropped session back on its own
(commands.rs schedule_reconnect): the page shows "connecting",
not "offline", and the resolver waits for it rather than
refusing the next track outright */
slsk.connection = "connecting";
slsk.redial = { attempt: ev.attempt || 1, inSecs: ev.in_secs || 0 };
}
});
on("soulseek:room-list", (rooms) => {
+22 -4
View File
@@ -15,6 +15,7 @@
import * as api from "../lib/api.js";
import { addPluginListener } from "@tauri-apps/api/core";
import { active, cancelAll } from "./transfers.svelte.js";
import { offline, cancelOffline } from "./offline.svelte.js";
import { note } from "./notice.svelte.js";
import { go, push, top, closeSheet } from "./nav.svelte.js";
@@ -44,14 +45,29 @@ let lastKey = "";
/** What is in flight, as the notice would say it — or null when nothing is. */
function shape() {
const live = active();
if (!live.length) return null;
/* a playlist being kept offline rides the same notice — and needs the
same foreground service, or the fetches stall the moment the app
leaves the screen (offline.svelte.js). Saves come first when both
are running; the kept rows are counted in. */
const keep = offline.active ? Math.max(1, offline.total - offline.done) : 0;
if (!live.length && !keep) return null;
if (!live.length) {
return {
count: keep,
title: keep === 1 ? "Keeping 1 track offline" : `Keeping ${keep} tracks offline`,
line: offline.title || "finding a source",
percent: offline.percent >= 0 ? offline.percent : 0,
indeterminate: offline.percent < 0,
};
}
const newest = [...live].sort((a, b) => b.id - a.id); /* the screen's own order */
const cur = newest.find((d) => d.state === "transferring") || newest[0];
let received = 0, total = 0, known = 0;
for (const d of live) if (d.total) { received += d.received || 0; total += d.total; known++; }
const n = live.length + keep;
return {
count: live.length,
title: live.length === 1 ? "Saving 1 file" : `Saving ${live.length} files`,
count: n,
title: n === 1 ? "Saving 1 file" : `Saving ${n} files`,
line: cur.state === "transferring" ? (cur.name || cur.path || "") : "waiting in their queue",
percent: total ? Math.round((received / total) * 100) : 0,
indeterminate: !known,
@@ -100,8 +116,10 @@ export async function bindXferNote() {
await addPluginListener("transfer-notice", "action", (p) => {
if (p?.action === "cancel") {
/* the same cancel(id) a row's tap takes, once per live row —
never a second path to the same thing */
never a second path to the same thing; and the offline
pass stops after the row it is on */
void cancelAll();
cancelOffline();
note("Downloads stopped.");
} else if (p?.action === "open") {
closeSheet();
+8 -2
View File
@@ -1614,7 +1614,10 @@ const MOCK_EXPECT = {
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText),
"artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice),
"artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix,
"artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),
/* the toast ("photos are back") is a transient the probe can land after
— it did on 2026-09-18 with the toast plainly in the still — so the
hero itself being gone (the monogram, no image) proves the same thing */
"artist-hero-cleared": (i) => !i.half.includes("up") && (/photos are back/.test(i.notice) || !i.heroImg),
/* the exhaustive page has NOT landed; the banner and the first four
releases have — three albums on the Albums tab, a single on its own */
"artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2,
@@ -1654,7 +1657,10 @@ const MOCK_EXPECT = {
deterministically the current file); xfnBad === 0 proves the call
reached a handler instead of being swallowed */
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13,
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn,
/* the Save's notice is gone — or the shade is now carrying a "Keep
offline" pass, which shares the notice and the service (offline.svelte.js)
and is not the Save this step took down */
"shade-gone": (i) => /^Keeping /.test(i.xfnTitle) || (!i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn),
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
+4 -2
View File
@@ -44,9 +44,11 @@ echo "== install $(date +%T)"
echo "== cold: force-stopping SoulStream $(date +%T)"
"$ADB" logcat -c
"$ADB" shell am force-stop com.soulstream.app
# SS_KEEP=1 leaves SoulStream running — the only way to test the WARM path,
# where the car hands a catalog row to an app that is up and has the resolver.
if [ -z "${SS_KEEP:-}" ]; then "$ADB" shell am force-stop com.soulstream.app; fi
"$ADB" shell am start -W -n com.soulstream.autoprober/.ProbeActivity \
--ei depth "$DEPTH" --ez recent "$RECENT" --es play "$PLAY" > /dev/null
--ei depth "$DEPTH" --ez recent "$RECENT" ${PLAY:+--es play "$PLAY"} > /dev/null
if [ -n "$PLAY" ]; then sleep 25; else sleep 7; fi
echo