"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.
169 KiB
SoulStream on Android
The phone is the same Tauri 2 project as the desktop: the same Rust core
in src-tauri, the same commands over IPC, a third Vite entry for the
phone's own UI. Started 2026-09-12 on a Pixel 6a (Android 16). The design it
implements is research/mobile-mockup/ (gitignored; see its README).
What is where
mobile.html → src/mobile-main.js → src/mobile/Mobile.svelte |
the phone UI (its own Vite input, next to index.html and mini.html) |
src/mobile/mobile.css |
the foundations: the desktop palette, phone radii/spacing, Android insets, the sampled springs, the registered --accent and its transition |
src/mobile/shell.css |
the shared vocabulary, global on purpose: ground, glass bar, cards, rows, chips, the record head, the mini bar, the nav, the player sheet, the lane, half sheets, the screen transitions |
src/mobile/nav.svelte.js |
a stack per tab; the browser history mirrors it one entry per back-able layer so the system back gesture peels the sheet, a half sheet, a pushed screen, then the tab |
src/mobile/player.svelte.js |
the queue (a predetermined shuffle order, the unshuffled queue kept), the transport, gapless preload the desktop's way (preload_local, track-ended { gapless }), the session in localStorage (a reload follows the live audio thread, a restart restores paused with needsResource), the sleep timer, the lane's story (hold → arriving), the play-history ticks (the desktop's rule, the same table) |
src/mobile/art.svelte.js |
covers behind a LIFO gate (3 in flight), the visible action, the accent per cover through src/lib/color.js; an https URL goes through the desktop's art proxy (catalog_art) |
src/mobile/lib.svelte.js |
albums, recents, follows, playlists; artists derived from the albums |
src/mobile/catalog.svelte.js |
the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id |
src/mobile/media.svelte.js |
the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions |
src/mobile/xfernote.svelte.js |
the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open |
src/mobile/slsk.svelte.js |
the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's slsk store and events |
src/mobile/contacts.svelte.js |
the local view of a person on the network: a petname, a group, a note and the two marks (contacts_meta_list / contact_meta_set) — this phone only, published nowhere |
crates/tauri-plugin-media-session/ |
a Tauri plugin with a Kotlin MediaSessionService (MediaService) and a SimpleBasePlayer over the Rust engine (SoulStreamPlayer), on androidx.media3. media3 posts and owns the card; audio focus and the becoming-noisy pause are still ours. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
crates/tauri-plugin-transfer-notice/ |
a second Tauri plugin with a second Kotlin foreground service (TransferService, dataSync) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing |
src/mobile/Sheet.svelte, HalfSheet.svelte, SyncRoom.svelte, Lane.svelte, Cover.svelte, Release.svelte |
the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) |
src/mobile/screens/ |
Home, Search (the library half: records and artists here, tracks through library_track_search), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
src/mobile/prefs.svelte.js |
Appearance, kept in localStorage: ambience, accent from artwork, motion |
src/mobile/util.js |
what every screen shares and no screen should write twice: the formatters (mmss / longDur / stamp for a length in seconds, in words and in milliseconds; ago / clock; fmtBytes / fmtCount; fmtOf / albumFmt), the name and folder readers a peer's file needs, the drag action, and devBuild |
src-tauri/tauri.android.conf.json |
merged over tauri.conf.json on Android: one window at mobile.html, no desktop resources, minSdkVersion 26 |
src-tauri/gen/android/ |
the generated Gradle project (npx tauri android init); minSdk = 26. Gitignored |
src-tauri/android/build.sh, deploy.sh |
the detour build (below) and build → install → launch → screenshot |
src-tauri/android/res/ |
the app module's own Android resources, tracked: the adaptive launcher icon, its monochrome layer, the legacy mipmaps and the gradient drawable the background is. build.sh and dev.sh copy the whole tree into gen/android/app/src/main/res/ before Gradle; verify-icon.sh reads it back out of the APK |
src-tauri/src/android_glue.rs |
JNI_OnLoad: hands the JavaVM and app Context to ndk-context (cpal's AAudio backend needs it; tao 0.35 never fills it) |
src-tauri/src/updater_mobile.rs |
the updater's stand-in on mobile (#[cfg(mobile)] #[path]) |
src-tauri/src/lyric_align_stub.rs, vocal_sep_stub.rs |
stand-ins off Windows for the two Windows ML modules |
tests/mobile/smoke.mjs, perf.mjs, cdp.mjs |
the phone UI walked in headless Chrome or on the phone, the frame-time probe, and the DevTools client they share (below) |
tools/auto-prober/ |
a throwaway app that binds SoulStream's MediaBrowserService the way Android Auto does and logs the tree as JSON — the only eye on Auto without a head unit, because a force-stopped app has no WebView and so no DevTools socket for smoke.mjs. Its own Gradle build, outside gen/android/ so tauri android init cannot delete it (below) |
Android-only manifest tables: src-tauri/Cargo.toml and
crates/metadata/Cargo.toml give reqwest rustls-tls on Android and the
platform TLS everywhere else (no OpenSSL to build against on Android). The
tables are exclusive on purpose — an unconditional reqwest entry unifies
default-tls back in. tauri-plugin-updater is a desktop-only dependency.
What the phone does differently at runtime:
- the library root is
app_data_dir()/Library(Android'sgetDataDir(), so/data/user/0/com.soulstream.app/Library) until the SAF folder story lands; - the Soulseek password lives in
secrets.jsonunder the app's private data directory (config::save_passwordand friends on Android) — there is no credential manager the keyring crate speaks to, and the app directory is what Android's account storage amounts to for an app; - the audio output stream asks for an 8192-frame buffer (
open_output_stream); AAudio's default put it on the fast path with a 10 ms burst, where the mixer's inline decode and file reads underran audibly; pick_folderanswers "nothing picked" (desktop-only dialog);save_fileopens Android's create-document sheet, but answers acontent://URI thatstd::fscannot open — which is why the playlist export goes through the storage plugin'screate_documentinstead;- the window-glass command is a no-op (the ground is the artwork);
- the aligner and the separator report "not on this platform";
- the desktop's Win32 perf counters read 0 / None in the debug overlay.
The shell, on real data
The mockup's rules, kept: the page ground is the artwork in play, blown up and
blurred (two layers, so a change of record crossfades); the accent follows
the artwork per screen — a record page wears the record's colour, an
artist page the artist's, everything else what is playing — extracted with
the desktop's own color.js and crossfaded by the compositor through the
registered --accent. The glass bar is GlassBar.svelte's recipe and stays
anonymous until the page's large title has scrolled under it. The mini bar
is the desktop's 62px row over a 16px lane strip; tapping it is a container
transform (the small art flies to the big one); the big art opens the artwork
viewer — the cover at full size, pinch to zoom, one finger pans while zoomed,
a tap on the unzoomed cover or the back gesture closes. The player's scrubber
is the lane itself, no knob. Motion is Material 3 Expressive's springs,
sampled into linear(). Reduce motion (You → Appearance, or the OS setting)
turns it off.
The activity declares no screenOrientation and handles orientation and
screenSize itself, so turning the phone over is a resize and nothing else —
the audio thread, the stack and the screen's scroll all survive it, and the
layout is the only thing that has to answer. Two media queries do that.
Every claim in that sentence was read off the CSS and proved at 915×411
behind a viewport override; the phone walk now turns a real device over and
reads each of them there — the angle, the insets around the cutout and the
gesture pill, the activity, the audio. See Verifying. The player sheet
is the one surface whose portrait shape cannot survive a wide viewport: its
artwork is a square sized by WIDTH, so at 915×411 the cover came out 879px
tall and the title, the lane, the transport and the face buttons sat
below the bottom of a sheet that does not scroll — a corner of the cover and no
way to pause. Sideways it is two columns: the head across the top, the cover
down the left sized by HEIGHT, and the track, the scrubber, the transport and
the faces stacked on the right. With lyrics or the queue up, the face takes the
cover's column and the thumbnail goes — the record is already the ground behind
the whole sheet. That is an aspect problem rather than a height one, so the rule
keys off orientation: landscape alone and a tablet gets the same two columns.
The rest is a phone-sized query (max-height: 560px). The nav comes down to
Android's own 48dp minimum and the mini bar to a 60dp row over the same lane
strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46%
of the height, it is 40%. The artist hero stops being a 300px portrait, which
covered its own Play button with the mini bar, and becomes a 190px banner: what
the desktop's hero has always been. The record head puts the cover beside the
title rather than above it, again the desktop's own shape, so Play is in view
without a scroll. A half sheet may take all but 36px of the height instead of
76% of it. Everything else — the grids, the rails, the artwork viewer, the
tap-to-sync room, the chat composer — already answered to width and was left
alone. viewport-fit=cover means the cutout and the gesture pill come in from
the SIDES in landscape, so the shell reads --inset-left / --inset-right
beside the top and bottom ones now. Those rules are appended at the end of
shell.css on purpose: they have to come after .gbar's padding, .mini's
and .toast's left/right to win at equal specificity.
Search and the resolver
Search is the desktop's top bar: the field asks the catalog once the
typing pauses (catalog_search — Deezer, MusicBrainz and Apple behind it),
and shows artists, a rail of records and songs, then what the library holds,
then — offered, not run — a raw Soulseek search grouped by peer (a peer's
copy plays from its row, the lane rising as the bytes arrive). A song row,
a catalog record's tracks and its play disc, and Popular on an artist page
all play through the resolver.
The resolver is the desktop's, not a fork of it: the source rules — the query
ladder with its poisoned-term hygiene, the paced search with its early exit,
the quality window, the candidate scoring for songs and folders, the
remembered stream sources — live in src/lib/sources.js, one copy for
both apps (moved out of state.svelte.js verbatim on 2026-09-12;
state.svelte.js imports them back). src/mobile/resolve.svelte.js runs
them on the phone: the library first, then a remembered source whose bytes
are still complete in the cache, then the network. A record queues every
track from the one folder that wins; a song played off a record queues the
rest of the record behind it as path-less entries that find their own copy
when their turn comes (hooks.resolveEntry). Failover is the player's: an
entry carries its runner-up peers in t.alts, and a stream that has not
sent a byte in 12 s (8 s for a retry) moves to the next one, then on.
A streamed record's cover comes out of the file's own tags once enough of it
is here (stream_embedded_art), and the accent follows it when it lands.
Search leads with the desktop's top result — the most popular exact match
of its kind (artist, record or song), scored as a share of its own kind's
leader, ties falling artist → record → song — on the featured surface, then
artist cards, records, songs. The artist page is the desktop's
(CatalogArtist + ArtistExtras) at phone scale: the banner with the wordmark or
the name, Play popular, Follow, the stat chips (releases, fans, years active,
genre, socials), Popular with listener bars, the latest release, the
discography filed the desktop's way (src/mobile/disco.js over the shared
localReleaseType.js + editions.js: tabs per category, editions folded
into families, the library's copies marked), releases by year, fans also
like, what is on this phone, About. Opened from a search row it carries the
row's identity (seed), so a namesake can never take the page.
A cold page no longer waits on one call for everything. Three lanes run off
the artist's id the moment it is known: Popular's ranked rows
(catalog_artist_popular), the banner alone (catalog_artist_hero_seed) and
the first page of releases (catalog_artist_overview) — the desktop's own
staging, for the desktop's own reason, since the identity pass inside the
full page is a MusicBrainz chain behind a one-request-a-second gate. The
overview's releases hold the discography up until the exhaustive page lands
with the classified one, and never the other way round; its popular_tracks
are dropped, because Popular already has a ranked lane of its own. The hero
seed pays for itself twice: its first source is the hero Kyle picked on this
phone, read off the file with no network at all, so Choose hero photo
shows on the next visit at once instead of behind a whole catalog page — and
both lanes are forgotten with the page when a hero is set or removed, or the
old banner would answer while the new one was still being fetched.
The whole discography is a page of its own
(src/mobile/screens/Discography.svelte), reached from the artist page's
Discography head with All, and from under its grid with "Show all N
Albums" — which is also where that grid now stops: it shows twelve of the
active tab, because before this an artist with 140 releases rendered 140
cards. The page is the desktop's CatalogDiscography.svelte reduced to the
four things it has that the artist page's tabs do not: All in front of
the categories (built by CONCATENATING the per-category families, never by
re-folding the whole set — a single sharing an album's base title would fold
into it as a phantom edition), a sort (newest, oldest, A–Z, Z–A), a
grouping by year, and the editions kept whole, opened in place under
their own record. Three of the desktop's are deliberately left behind: the
per-record tracklists (140 records is 140 catalog_album calls over mobile
data, for rows one tap away on the record page), the per-record accent
sampled from each cover, and the scroll-led banner. The year is written into
the list where the desktop has a sticky year rail — a phone list is read top
to bottom. The order and the grouping live on the nav entry and are edited
from the Sort & group sheet, Library's own idiom; under a name order By
year is not offered, because the years would repeat out of sequence, and the
sheet says so. The list is capped at sixty rows with a "+ N more releases"
button (Browse's rule), and the cap is taken BEFORE the grouping, so the last
year on screen can be a part of itself. Filing, families and labels all come
from src/mobile/disco.js — the artist page's own classifier, called a
second time over the same cached albums. The desktop cannot do that: its
filing depends on a background description harvest and deferred MB type
hints a second page would not read, which is why CatalogDiscography.svelte
takes the artist page's computed tab list as the same array object instead.
The catalog record page is the desktop's album page: the kind, the
artist byline with a portrait, year · tracks · runtime, Last.fm's totals,
Play / Save / Shuffle, the record's other editions (the artist's page folded
by editions.js, the desktop's own rule), the tracklist with listener bars
and disc heads, About this record (Wikipedia through catalog_album_info,
the lede promoted), and the release pills with who said so. Save fetches
the best folder by the DOWNLOAD rules into the phone's library folder; the
pipeline files it and says soulseek:library-changed, which the library
store watches, so the record appears on its own. A partial copy (a Save cut
short, a rip missing tracks) keeps the button as "Complete · N missing". A
long press on a row is the track sheet: Play, Save to library, Go to record /
artist. Verified 2026-09-13: Discovery, 14 FLACs from one peer, whole in the
library folder 31 s after the tap, filed as "Artist/Year - Album/… - NN -
Title.flac" with cover.jpg beside them.
The THREE next tracks are readied while the current one plays — the
desktop's depth (STREAM_PRELOAD_DEPTH), in the player's armPreload: a
library file is decoded and appended; a peer's copy is fetched into the
cache ahead of time; a record's later track that has no copy yet is found
first, QUIETLY (a background search never shows the resolver's line); the
immediate next is appended the moment it is byte-complete, so the boundary
is gapless. Streams wait four seconds after a start so nothing competes with
the track that is starting. Verified 2026-09-13 on a record the phone had
never played: the first track resolved and started in 14 s, the next three
were found and the first of them cached by 16 s, and the boundary crossed
gapless (player.advance says how each boundary was crossed, "gapless" or
"sourced" — the harness reads it).
The library folder (You → Library → Library folder) is picked through
Android's document tree picker in crates/tauri-plugin-storage (the same
plugin shape as the media session: links, build.rs with
android_path("android"), the capability storage:default). The picked
tree's content URI is kept with a persistable grant and resolved to a real
path when it is on a volume that has one (primary:Music →
/storage/emulated/0/Music; an SD card's XXXX-XXXX: too; a cloud
provider's tree has none and is refused), because the Rust scanner and the
download pipeline work on paths. That path becomes download_dir — the
folder IS the library — and a rescan follows. A folder in shared storage
needs Android's all-files access under scoped storage: the plugin
declares MANAGE_EXTERNAL_STORAGE, reports whether it is granted, and opens
the system screen that grants it (You shows the row only while the folder
is outside the app's private data and access is missing). Nothing is read
outside the folder the user picked.
A file from the phone is the same plugin's pick_file, beside
pick_folder and pick_image: Android's document picker with the caller's
own MIME filter, answered with { path, name, size, copied }. It copies as
little as it can, because an image is small and an audio file is not. A
document already on this phone's storage hands its own path over as it lies
— the DOCUMENT sibling of the tree id the folder picker resolves, plus the
Downloads provider's raw: — so picking a 300 MB file costs nothing and
touches no cache. One that has no path (a cloud provider's, a document a
provider only streams) is copied into cacheDir/picked under its own
display name, and only as far as the caller's maxCopyBytes allows: zero
refuses the copy outright, which is what an audio-sized pick wants, and the
copy counts itself as it goes because a provider's stated size is often
absent and sometimes wrong. Those copies are swept at the start of every
pick — unlike pick_image's, which live in the cache for ever — so a path
from one pick is gone by the next. The display name travels beside the path
because it is load-bearing: playlist_formats::detect_format reads the
extension off it and parse titles the draft from its stem, so a copy under
an invented name would read every playlist as plain text. The dialog plugin
cannot serve here: tauri-plugin-dialog does open the sheet from Rust, but
it answers with a content:// URI, and std::fs cannot take one.
Playback (You → Playback) has the desktop's volume normalization switch (−14 LUFS through the player crate's BS.1770 meter; on by default). There is no graphic equalizer in the app — the "eq" in the code is the animated bars glyph.
Home is the desktop's landing page on top of the library rows the phone
had: one slot per followed artist (src/mobile/home.svelte.js — their
catalog page, the MusicBrainz-only records, six related artists, warmed by
home_warm), the feed's lead on the featured surface (a new or coming
record; the newest record here when there is none), New from your artists,
the news, Jump back in, From the back catalogue, Recommended for you (each
card names the follow that earned it), Recently added, Out now, Complete the
set, Following. The rules are the desktop's, copied; a follow made anywhere
re-walks the slots (soulseek:follows-changed).
Gaps (Library → Gaps) is the desktop's gap line per shelf: only an artist
you FOLLOW is asked (an anchored identity, never a name guessed onto a
namesake), the catalog's released records minus the shelf's by loose title,
dimmed cards that open the catalog record page, the totals remembered in
library.gaps.v1 so the line says its number before the lookup.
The server's room list on Community is filtered by name and shows 80 at a time with a way to the rest (Soulseek lists hundreds).
Community's fourth tab is Taste: the interests this phone has
declared to the Soulseek server (slsk_interests_mine, slsk_interest_set)
and the people the server ranks as close to them (slsk_similar_users), a
row each into the peer sheet. A like or a hate is a chip and tapping one
takes it back; "Add a like" and "Add a hate" declare a new one. Every
declaration asks the server again and the chips stay quiet until it has
answered, because the ranking only moves when its input does. The lists
are this client's own — the backend keeps them and re-declares every one
at login, since the server's copy belongs to the account and another
client signed in elsewhere can change it under us. Mock-walked only: a
live sign-in and the server's own ranking are Kyle's to see.
A peer, and their shares
A raw result's ⋯ (or a long press on its head) is the peer's sheet: play
the folder as a record, save it to the library as it is, browse their
files, message them, add or remove them as a buddy. Browse is the
hub's Browse tab as a screen (screens/Browse.svelte, slsk_browse_user)
— the same sheet is a buddy's on Community → Buddies (a long press), and
"Look up a user" there opens it for any name typed (the hub's browse by
name):
every folder they share with its files, sizes and formats, the totals in
the header; a folder opens to its files, plays through the stream engine
or saves whole. One request for the whole share, so a big one takes a
moment — and a share can hold thousands of folders (the first peer the
walk met: 7,511 folders, 158,937 files, 2.4 TB), so the screen shows a
filter and renders 80 at a time with a "more" button; rendering all of
them at once cost an audio dropout. The phone walk browses the first peer
of its network search.
Every name in Community can wear one you gave it. The peer's sheet ends in
Name and notes (src/mobile/contacts.svelte.js, contacts_meta_list /
contact_meta_set): a petname, a group, a private note and the two marks,
Favorite and Trusted. All of it sits in the app's own config on this phone
and is published nowhere — Soulseek never learns it, and neither does the
person it describes. The petname stands in for the username on the buddy
list, in the chat list, on a thread's bar, in the peer sheet, in a room's
roster and on a room line, and the real username stays with it — on the
line beneath, or beside the name in a room — so a stranger is never quietly
turned into someone you know. Favourites sit at the top of the buddy list,
a star and a shield mark the two, and clearing a person's details drops the
entry rather than storing an empty one — the backend's own rule.
More long presses: a catalog song in the search results opens the catalog
track's sheet (play, save, add to a playlist, the record, the artist); a
row of the queue face opens the track's sheet with Play now, Move to next,
Move up, Move down and Remove from queue on top; a raw result's file (and a file on the Browse
screen) opens its own — play from here, save this one file, browse; a name
in a room's list opens the peer sheet. The catalog record page's bar ⋯ is
the record's sheet: play, save, add every track to a playlist as streams,
the artist — and the same sheet is a long press on any catalog card (the
results' Records rail, Home's feed, the Gaps shelf). The now-playing
sheet's lyrics face has its tools on a long press or the link under the
words (lyrics.svelte.js, the desktop's): the offset nudged half a
second at a time and kept for the track, another LRCLIB candidate chosen
when the words are wrong, the lookup forgotten and done again. A record's
sheet re-fetches its cover (the desktop's per-record refresh); You →
Library clears the play history behind a confirm. The now-playing
sheet's ⋯ on a streamed track is the desktop's
Save menu: Save to library (at once when the cache is byte-complete, else
through the download pipeline) and Save this record for the streamed
record the queue holds; beside them, "Copy to a folder…" writes that same
cached stream into a folder picked on this phone, as the plain file it is
(copy_stream_to_folder — no re-download, no tagging, no library row; a
track that has not finished streaming is counted back, never fetched).
A moment after boot the phone warms the library's thumbnails the
desktop's way (library_art_prewarm at the cards' edge, one decode at a
time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
or LRC with timestamps, kept for the track).
The rest of the lyrics tools came with it. The sheet carries the per-track
auto-sync switch (lyrics_set_auto_sync), so the timing correction the app
makes from a file's own lead-in can be turned off for one recording and back
on. "Re-time by hand" opens the tap-to-sync room (SyncRoom.svelte, one
more layer the back gesture peels): the song plays, the line to time sits
large in the middle of the screen with the one behind and the one ahead
faint around it, and a tap anywhere stamps it — the last tap undone, −5 s
and play/pause under the thumb, and Save writing the track's own document
(lyrics_save_lines, the top of the waterfall, where a paste lands too).
Lines never tapped are left out of it and the button says how many are kept;
a set left mid-tap by the back gesture is offered back by the row that
opened it.
The room also measures the recording. lyrics_vocal_activity is the
desktop's coarse vocal-band envelope — the centre channel band-passed to
180 Hz – 7.5 kHz, one value per 100 ms, read on a blocking thread — and it
draws as a strip under the head, so the singing ahead is visible while the
thumb waits for it. Estimate the timing lays every line across the
stretches the envelope says are sung, weighted by line length, through the
desktop's own generator (src/lib/lyric-sync.js, shared and unit-tested):
never an even spread over the duration, and a refusal rather than a guess
when the measurement shows nothing usable. The result is labelled an
estimate everywhere it shows — the hint under the line, the Save button —
the cursor goes back to the top so the taps correct it from there, and one
more tap on the button drops the whole thing back to an empty set (a rough
fill stamps every line at once, so Undo has nothing left to take back).
A streamed track has no finished file to measure, so the button says there
is no estimate for that one — the same rule the reduced-vocals copy follows,
and the same whole-file decode behind both.
"Play with reduced vocals" is the desktop's offline DSP
(lyrics_prepare_instrumental — a band-limited centre cancel, not a
separator, and the wording stays honest about that): the copy plays under
the same title, artist and duration, so the lyric identity is still the
recording's and the words keep following it, and one row puts the original
back.
One switch the desktop never needed: You → Downloads → "Downloads on
Wi-Fi only" (network.svelte.js, off by default) holds every Save — a
record, a track, a folder, a file, a playlist's bulk, the now-playing
Save — while the phone is on mobile data (navigator.connection.type);
streaming is never held back. The nav wears two badges the desktop's chrome has: transfers in flight on
You (the title bar's download ring) and unread chats on Community.
The Library's count line carries the collection's hours and the sort
offers By format (lossless by depth and rate first, then by bitrate); the
empty Search page shows Jump back in, the desktop's. The Library's cards wear the desktop's file stats (library_album_stats,
one call for the whole library: FLAC 24/96, MP3 320k, the size on the
list rows and the record page), and the artist page shows this phone's
own listening (artist_play_stats: time played as a chip, the most
played track under "On this phone"). Popular is asked on its own as well as
with the page (catalog_artist_popular: the Last.fm ranking and its counts,
waiting on nothing but the linked MBID, so the section can paint before the
full page is built — and an artist whose page came back with no Popular at
all still gets one), and a catalog record's rows that the album's own artist
anchor found no count for — a feature's guest, a compilation's stranger —
get the desktop's deferred pass by their OWN Deezer artist id
(catalog_track_listeners). An artist's photo is Kyle's to choose: the
artist page's ⋯ offers Choose hero photo (the phone's image picker, then
set_artist_hero — pick_artist_hero's install step split out and shared,
so the desktop's dialog keeps its own door; 1920×800 and landscape or it is
refused) and Remove custom hero, which puts the catalog's own photos
back; the page is asked again either way, so the banner, the ground and the
accent all follow. The desktop's own chooser has had no home since its pill
left the glass bar — the phone has one first. You → Album artwork carries
Refresh artist portraits (refresh_artist_art), the desktop Settings
row. A data: URL is the image itself in art.svelte.js now — a chosen
hero comes back from Rust as one, and the thumbnail fetch used to throw it
away. Search corrects an obvious
misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule,
a word only when a real name is within two edits.
Tapping About this user on the peer sheet opens the stranger in full
(screens/Peer.svelte, push("peer", { username })): the card their own
client serves (slsk_user_info — what they wrote about themselves, their
picture, whether a slot is free, the queue, how much they have served),
the likes and hates they declared to the server (slsk_user_interests),
and the comparison this phone makes of their share against the library
(overlap.rs: overlap_compute browses them on purpose, overlap_list
reads what was compared before, overlap_forget drops it). Nothing is
uploaded to work it out — their folder names against your albums, here.
The comparison's rows are the reason to open a stranger: every record they
have by an artist in your library that you don't, each one opening the
catalog search that finds it. Their picture opens in the artwork viewer,
which takes a data: key now as well as a library path and an https URL.
Playlists
The desktop's two playlist pages (src/views/Playlists.svelte,
Playlist.svelte), at phone scale, on the same commands
(list_playlists, playlist_items, playlist_cover, create / rename /
describe / pin / duplicate / delete, playlist_add_items,
playlist_remove_items).
- The shelf is Library → Playlists (
src/mobile/PlaylistShelf.svelte): the stat line, Pinned then All, the custom cover or the composed mosaic on every card (fetched once per revision —coverForinplaylists.svelte.js), a Setlist / N unresolved badge, New playlist (a prompt sheet). A long press on a card is its sheet: Play, Shuffle, Pin, Rename, Duplicate, Delete (a confirm sheet). - The page (
screens/Playlist.svelte) is the record page's shape: the cover or mosaic, the kind and a Pinned chip, the description, the meta line with its warnings (N unresolved, N missing), Play / Shuffle / More, a filter, and the rows with the desktop's tags —unresolved,set audio,file missing. Setlist cue times show only when every row carries one. The bar's More is the page's sheet: Pin, Rename, Edit description, Duplicate, Remove unresolved entries, Delete. - Playing follows the desktop's
queueableRows: Play queues the rows that can play — present local files as themselves, stream rows as path-less entries with their catalog identity, which the player resolves on reach throughhooks.resolveEntry(the same path a record's later tracks take, so the three-ahead preload covers them). A tap on a weak row with a name streams it one-off; a bare placeholder opens its sheet. The playing row is found byplItemIdon the queue entry. A set-recording slice starts at its cue (t.startSecs) and the player moves on at the next cue (t.endSecs); the scrubber still shows the whole recording. - A row's sheet (
pltrack): Play from here, Play next, Add to queue, Save to library for a stream row, Find a local match for a row without a file (the desktop's repair panel as a sheet: the library searched by the row's name, a pick relinks the row throughplaylist_relink_item; an unknown placeholder adopts the track's identity), Go to artist, Remove from playlist; Move up, Move down and Move to the top reorder from the sheet (playlist_set_order, the whole order in one call, as the desktop's drop does) — the accessible path, and the one that stays honest while the filter is on. A removal is undoable for seven seconds from the neutral toast (notice.svelte.js— the desktop's undo pill); the stash pins the playlist it came from. - Drag to reorder (
src/mobile/reorder.js— one Svelte action, on the container, for the playlist page's rows and the player sheet's queue alike). Press and hold a row for 320ms and it lifts (with a short tap in the phone's motor), the rows below make room, and letting go lands it. Release without having moved and the row's sheet opens exactly as a long press always opened it — which matters most on the queue, where a row has no ⋯ andoncontextmenuis the only door to Play now, Move to next and Remove from queue. A press that moves more than 8px before the hold lands is a scroll, and the gesture lets go of it. Nothing carriestouch-action: none: a flick that starts on a row still scrolls the list.touch-actionis latched at touchstart and cannot be flipped mid-gesture, so the page's own scroll is refused instead by a non-passivetouchmovelistener added at the lift — which works because the finger has been still for 320ms and the browser has not started a scroll yet. A click and a contextmenu guard sit on the container in the CAPTURE phase while a lift is live (a released drag would otherwise fire the row's own click and play the track, and Android's own 500ms long press would open the sheet in the middle of the drag); both are off when no lift is live, so a tap and a long press behave as they always have. Every row is measured once at the lift, in the scroller's CONTENT space, so the edge pull — within 64px of either edge, ramped, up to 14px a frame — leaves the snapshot true and the row keeps up with the finger. Each displaced row moves by the exact difference between its own top and its neighbour's rather than by one assumed row height (the desktop'srowH), because the queue face puts an album separator between rows. The drop reuses the mutations the Move rows already use:moveItemandmoveInQueueeach take a numeric target now as well as"up"/"down"/"top", so there is still exactly one call toplaylist_set_orderwith the whole id order. It is optimistic — the rows go where you put them at once, and come back with the error toast if the backend says no. Two refusals guard the middle of a gesture: the playlist drops nothing if a refetch has replaced its rows (pls.draggingalso defers the page's own re-read while a drag is live, the phone's version of the desktop'sskippedWhileDrag), and the queue drops nothing ifplayer.qihas moved, because a track boundary shifts the whole rendered window and the absolute indices would then address the wrong rows. Dragging is off while the playlist's filter has anything in it — a filtered list's visible order is not the stored order — and the note under the field says so; the sheet's Move rows address rows by id and stay correct under a filter, which is why they are the way to reorder without clearing it. - Add to library / Download all tracks on the page's sheet — the
desktop's bulk actions (
runBulkinplaylists.svelte.js, the rules verbatim): the first files every already-streamed track whose cache is byte-complete, the second finds a source for every stream row (the remembered one, else the ladder) and hands it to the download pipeline; the page shows the run's line with Stop, then its summary. - The cover: Choose cover photo on a playlist's sheet opens the phone's
image picker (the storage plugin's
pick_image: the picked content is copied into the app's cache, andplaylist_cover_setinstalls it the way the desktop's dialog pick does — squared, the master cover, the row's revision moved); Remove custom cover brings the mosaic back. - Add to playlist is on every track and record sheet: a pick-a-playlist
sheet (New playlist at the top). A file on the phone becomes a local row;
a streamed or catalog track keeps its identity (
track_id) and cover URL and becomes a stream row. - Every mutation re-lists through
soulseek:playlists-changed, which the desktop's commands already emit; the page re-reads its rows when the playlist's revision moves. - Importing (
importer.svelte.js,screens/Import.svelte): the shelf's Import opens five doors — a playlist file on this phone (M3U · M3U8 · PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the backend dispatches it by host), a pasted tracklist (plain lines, a timestamped setlist, or track links), a YouTube DJ set's posted tracklist, and a folder of audio files on this phone. A paste holding even one track link takes the link path (playlist_import_track_links, the desktop's own detector verbatim): each line is read off its own public page — the way past Spotify's 100-track public ceiling — and the wait screen counts them as they come. The folder door picks with the storage plugin's document-tree picker and hands the resolved path toplaylist_import_folder, which grew apathargument for it: on the phone that command had no dialog to open and always answered nothing. The file door does the same through the plugin'spick_file, andplaylist_import_probegrewpathANDnamefor it — the picked document's own display name is whatdetect_formatreads the extension off and what titles the draft, and a document this phone had no path for arrives as a cache copy under a sanitized filename. A copied file's rows still find the library: a relative locator that misses falls back to the basename index, which is the one thing that reads differently from the desktop, and the sheet says so. The backend stages the draft over plain HTTP (the same commands as the desktop, no yt-dlp involved), the review screen shows every row's verdict, the stream rows are verified against the catalog while it is up (the desktop's pass, verbatim), and Create swaps the review for the playlist it made (nav.replace). - Exporting (
playlist_export, the playlist's own sheet and the shelf card's): the desktop's five profiles, in the desktop's order and its own words — M3U8, XSPF, PLS, CSV and the backup JSON. The file is built in Rust byplaylist_formats, ABOVE the platform fork, so the phone never rebuilds a format and the two exports cannot drift. The doors differ because the answers do:tauri-plugin-dialogdoes open Android's create-document sheet, but it answers with acontent://URI andstd::fs::writecannot take one, so on the phone the bytes go totauri-plugin-storage'screate_documentand its Kotlin writes them through the ContentResolver (openOutputStream(uri, "wt")), wherever the sheet pointed. That is a plugin function called from Rust, not a command: the file never passes through the WebView, and nothing was added to the ACL. A dismissed sheet resolves nothing and the phone says nothing — which is the desktop'sOk(None). A row with no local file exports exactly as it does on the desktop: named, with a comment where the location would be (M3U8), carried without a location (XSPF, CSV, the backup), or left out (PLS, which holds only real files). The locations that ARE written are this phone's own paths, inside the app's folder — the format behaving correctly, not a bug to rewrite. - The share sheet (
crates/tauri-plugin-share,share.svelte.js): Share → SoulStream from any app. The plugin'sShareActivity(an ACTION_SEND text/plain target, merged into the app's manifest from the plugin module) keeps the text and brings the app's own task to the front through its launcher intent; the WebView askspending_shareat boot and whenever it comes back to the front, and a link opens the link door (the first URL in the text — Spotify shares the link inside a sentence), anything else the pasted-tracklist door. The phone walk sends the intent itself (adb shell am start -a android.intent.action.SEND …).
Not on the phone yet: the listener door (it needs yt-dlp, a desktop tool), and Share on SoulStream.
The first run
Every walk and every deploy so far started from Kyle's own phone — signed in, library folder set, a config full of his settings. What a phone that has just installed the app actually has is different in three ways, and only one of them is what you would guess.
The library root is never missing. config.rs's download_dir has an
#[cfg(target_os = "android")] branch that falls back to
app_data_dir()/Library and returns before the desktop's audio_dir()
fallback is reached, so a fresh install has a real, writable, empty library
root at /data/user/0/com.soulstream.app/files/Library — invisible to
every other app on the phone, including the one that put the music there.
pipeline::init scans it at startup and library_albums honestly answers
[]. This is why "no folder yet" cannot be a null check anywhere: the test
is the PATH, isPrivateFolder in setup.svelte.js, which You.svelte had
first and now imports rather than keeping a second copy of.
setup_done was already there and the phone never touched it. It
round-trips through get_settings/set_settings like any other field and
config::setup_done unwraps a missing one to false, so every phone in
existence reads as unstamped, Kyle's included. Gating the tour on that
flag alone — which is what the desktop does — would have opened a setup
sheet on a phone already full of his own music, and then written the flag
into his config on dismissal.
Nothing is signed in, and everything says so honestly and helpfully
nowhere. connectStored() returns at once with no account saved, the
connection stays offline, and every stream and every Save ends as a red
toast with a sentence in it and no way out.
So: src/mobile/setup.svelte.js is the one store, and there are two
surfaces.
The tour is a half sheet (kind === "setup" in HalfSheet.svelte) —
one scroll, two cards, a foot. Where your music lives: the current folder,
a row that opens the storage plugin's folder picker, and the all-files
permission row beside it once a real folder has been picked. Soulseek: what
it unlocks, and one row that goes to the sign-in card on Community. The
tour never handles a password — there is no field in it; Kyle signs in
himself, on the card that already existed. Keeping the private folder and
staying signed out are both legitimate answers, so the sheet is answerable
by doing nothing, and the scrim and the back gesture close it like any
other half sheet.
The boot gate is a three-way AND — !setup_done && !slsk.savedUser && !lib.albums.length — and it is evaluated only after loadLibrary() and
connectStored() have both settled (Promise.all in Mobile.svelte's
onMount). Read either of those early and they are [] and "" on every
phone there is. Kyle's phone fails the second and third clause, which is
the only safe way to ship an auto-opening layer into a pipeline that
installs onto his device.
The note (SetupNote.svelte) is the fallback for the screens that are
empty because of the same two things, with the row that fixes each: Home's
empty state (whose copy still said music "lives in the app's own Library
folder for now", written before the picker landed in You), Library →
Albums, Library → Artists — which had no empty state at all, just a
0 artists · 0 followed line over nothing — and Search's empty page, where
"Sign in on Community to play what the library does not hold" was a flat
sentence and is now the row that does it. A row is drawn only for a thing
actually missing, and the component renders nothing when both are answered,
so a phone that is set up never sees it.
There are two doors onto the same folder — the tour's row and You's own
picker — and they tell each other: You re-reads when setup.folder moves,
and its picker calls loadSetup() on the way past, or the note on Home
would go on offering a folder that had already been picked on You. The
all-files row does the same trick You does with the grant: the system
screen takes the app away and gives it back, so requestAccess re-reads
the answer on the way back rather than leaving a row up that has been
answered.
The preview door is one row in You → This phone: "Set up this phone
again", openSetup("preview"). A preview reads the config and writes
nothing — setup.writes, the number of times the store has stamped
setup_done, stays where it found it. The phone walk drives exactly that
door on Kyle's phone and asserts the counter at zero on the way in and on
the way out; it types nothing, picks nothing and clears nothing.
The harness hosts the other device. localStorage.mockFresh, or #fresh
in the URL (which is what the walk uses — a reload cannot lose a fragment),
switches src/lib/mockLibrary.js to a fresh install: every library
command answers [], get_credentials answers with no account,
all_files_access is not granted, and mockSettings is mutated in place
so the library root is the sandbox path and setup_done is false —
mutated rather than replaced so set_settings still works and picking a
folder in the walk genuinely moves the state the notes read. The
fresh-install block is the LAST thing in MOCK_STEPS because its reload
wipes every module's state, and it turns the flag off and reloads again at
the end so that anything appended after it is not silently running against
an empty device.
Two things a reload does NOT clear on its own, both handled in that block
because it is the first one on the mock leg. The webview's storage comes
back with the page, and the player writes the session out on pagehide,
which a reload fires — so removing the key up front puts it straight back;
the block adds a pagehide listener of its own, last and therefore run
last, and a phone with nothing in it comes up with no now-playing bar. And
the viewport emulation lives on the DevTools session rather than on the
page, so the re-attach after a reload drops it: the walk keeps what it set
and hands it back, or every still from there on would be the Chrome
window's own size at 1×.
Two commands the phone can reach had no mock at all until this round —
player_control and open_external — found by mapping every api.js
wrapper used under src/mobile against the mock (126 wrappers, 124
mocked). Unmocked they fall through to the real IPC, which on a file://
page rejects: a console warning and a toast, in whatever step happens to
touch them first. The transport now answers the way the real core does,
with an event; resume cannot restart the position ticks, so the state is
right and the position stands still.
Settings that reach the config
set_settings takes camelCase arguments (Tauri's rule for command
parameters): { volumeNormalization, downloadDir, streamSourcePrefs, downloadSourcePrefs }. The phone's first You screen sent snake_case keys
and every save was silently ignored (found 2026-09-13 while adding the
Sources group — the walk now flips the switch and a chip and reads each
back from the config). You → Sources is the desktop's Source quality
section: per mode (streaming, downloads) the ceiling (no limit / lossy
only), the floor (any … lossless only) and the priority (best quality /
fastest transfer); the resolver re-reads them (refreshSourcePrefs).
You → Streaming is the desktop's Streaming section: how long the stream
cache keeps a track after its last play (or Forever — everything streamed
goes to the library) and the cache's size limit. Downloads (the album
folder format), Sharing (the library folder is always shared, the upload
slots, extra shared folders picked on the phone), Lyrics (word-by-word
fill) and Album artwork (canonical vs the uploader's art, the embedded
cover size, the folder cover, re-fetch all covers) follow the same
sections of the desktop's Settings. Not on the phone: the account (sign-in
lives on Community), the networks, updates (the APK comes from the build),
the lyric alignment models (a desktop runtime), artist artwork's upscaler.
Search's empty page lists the recent searches this phone used (the last
eight: Enter, or the screen left with a query in it), kept on the device.
Sharing carries one thing the desktop never exposed at all: the listening
port (listenPort), what other peers dial when they download from you.
Unset means the client's own 50300 (DEFAULT_LISTEN_PORT; bind walks
forward a few ports if it is taken); the app asks the router to forward it
over UPnP and falls back to the server relaying the connection, and a
change applies on the next connect — on mobile data the carrier's NAT
blocks incoming connections whatever the number is. set_settings only
assigns a port that is Some, so it can be changed but never taken back to
unset: the phone walk saves a spare number and then 50300, which is the
port an unset config already used.
The dev flavour gets one more row under This phone: Diagnostics
(screens/Diagnostics.svelte), the desktop's Ctrl+Shift+D overlay as a
screen — process and system CPU, memory, the network rates, the track
playing with its loudness correction, the tracks the pre-buffer is
fetching, and how big the play history has grown (debug_stats,
play_row_count), polled once a second while it is open and never
otherwise. devBuild in util.js is the gate: Vite's own DEV flag, true
only for the build that loads from the dev server over the cable, so the
embedded build the phone keeps has no row to tap. The GPU counters are
Windows PDH and the private commit is a Win32 call, so neither is on the
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
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 →
src/mobile/media.svelte.js, which did not change.
The session, the player, the notification provider, the audio-focus request
and the becoming-noisy receiver all live in MediaHub for the life of the
process, and MediaService.onCreate allocates and registers nothing. That
is not tidiness, it is androidx/media
#3158 — open since
2026-04-03, still open, reproduced on media3 1.11.0: a host that binds a
MediaLibraryService while nothing is playing can put the service into an
onCreate → onBind → onGetSession → onConnect → onDestroy loop at about
thirty cycles a second, and it is 100% reproducible after a force-stop with
no existing binding, which is exactly how Android Auto and
tools/auto-prober/ connect. The loop is Android Auto's to fix and media3
has said it will not act first, so it cannot be fixed from here — only made
harmless, by making a cycle cost nothing. Round forty-eight's
onCreate/onDestroy would have built a platform session and a legacy
browser stub thirty times a second, registered and unregistered a broadcast
receiver sixty times a second, and abandoned audio focus thirty times a
second while music was playing.
Three consequences, and each is a trap for the next round. onDestroy
calls removeSession, never release(): removeSession drops the session
from that instance's map and clears its listener, which is what stops a
destroyed service being called back, while SoulStreamPlayer.markReleased()
would latch released = true and leave a process-lifetime player that never
updates the card again. The session is built with
MediaLibrarySession.Builder(applicationContext, …) and not the
(MediaLibraryService, …) overload, because
MediaSessionServiceLegacyStub.initialize() does
attachToBaseContext(sessionImpl.getContext()) — a session built against the
Service pins a destroyed Service as the base context of a live
MediaBrowserServiceCompat. And nothing releases the session at all any
more, so it and its platform MediaSessionCompat live until the process
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
advertised at all, which is correct.
The glyph in the status bar is the falcon, not a note.
ic_soulstream_note.xml is the logo kit's monochrome path verbatim
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
wrapped in a <group> that re-frames it for a 24dp box — an Android path
element has no transform attribute, so the SVG's own
translate/scale/translate chain is folded into the group's numbers and the
falcon lands x 4.91→19.09, y 2→22. The fill stays #FFFFFFFF on a
transparent viewport because Android composites a notification's small icon
from its ALPHA CHANNEL and tints it itself: a coloured export renders as a
white blob. Nothing in the walk can tell those two apart — dumpsys notification prints the icon's RESOURCE ID and never a pixel — so the glyph
is a screenshot, and the only thing the walk can prove is the thing that
actually breaks. media-card proves it: after resumed, the card is still
posted, on the playback channel, carrying the playing track's own title,
and media-keys a line below still gets the transport back.
The card's artwork falls back to the app's own icon. MediaHub.artBytes
is cleared the moment artKey changes and only refilled once the frontend
has decoded a cover into bytes, and a track with no cover never refills it
at all — so without a fallback every track change has a window where the
lock screen shows a grey hole. MediaHub.appIcon() encodes the icon ONCE
and keeps the PNG bytes (getState() runs on every invalidation, so a
per-update encode would be a real cost), through the PackageManager rather
than a resource id because a library module's own R has no ic_launcher. It is drawn at
setBounds(0, 0, 512, 512), and that is less obvious than it looks: an
AdaptiveIconDrawable's bounds are the 72 units the launcher shows, not
the 108 its layers are drawn in — it scales its own layers up by 3/2 and
masks them inside whatever box it is handed, which is why its intrinsic width
is two thirds of the layer PNG. So the plain call already IS the launcher's
view, and a box set outside the tile to "reveal the 18% bleed" does the
opposite: it crops into the safe zone. The kit's foreground inks y 20.5→86.5
of 108, so a box 128 units oversize on each side would have shown 30→78 —
the head and the tail cut off. The same bytes go across as
MediaMetadata.artworkData, which media3 decodes itself — one
BitmapFactory call this plugin no longer makes.
android:appCategory="audio" is contributed by the media-session plugin's
own library manifest. The app manifest lives in gitignored gen/ and cannot
be edited from the repo, and it declares no category — so the merger takes
the library's, which is the one route there is.
Android 12+ draws a splash whether we ask or not, and the platform's own is a
white flash; Theme.App.Starting puts the brand ground (#05080B, the kit's
night) and @mipmap/ic_launcher there instead, and takes the window
background with it so the frame before the WebView's first paint is not white
either. It is delivered as a tracked res overlay, src-tauri/android/res/,
laid over the generated project by build.sh and dev.sh — the same move
build.sh already makes for proguard-soulstream.pro, and for the same
reason: gen/ is regenerated and gitignored, so nothing of ours survives in
it. The overlay carries BOTH values-v31/themes.xml and
values-night-v31/themes.xml, and that pair is not belt and braces: the
resource qualifier order puts NIGHT MODE ahead of PLATFORM VERSION, so on a
dark-mode Android 14 phone the generated values-night/themes.xml beats a
lone values-v31/ and the splash is silently ignored — on this phone, most
likely always. The style IS Theme.app (its parent), not a theme nothing
references, because androidx.core:core-splashscreen is not a dependency of
the app module: the platform attributes are the whole mechanism, there is no
postSplashScreenTheme, and Theme.app already is the post-splash theme.
Both overlay files replace Theme.app outright on API 31+, so anything a
later round adds to the generated values/themes.xml has to be added to them
too. The splash is a screenshot as well — it is gone before the walk's first
frame and dumpsys has no word for it.
A trap: tauri-build regenerates gen/android/tauri.settings.gradle and
app/tauri.build.gradle.kts — the lines that include every plugin's Android
module — ONLY when TAURI_ANDROID_PROJECT_PATH names the project, which the
Tauri CLI sets and a hand-driven cargo build does not. Without it the plugin
compiled, the APK packaged without its Kotlin, and the app died at start-up
with a ClassNotFoundException. src-tauri/android/build.sh exports it;
after adding a plugin, touch src-tauri/build.rs too, so the app's build
script re-runs and rewrites the wiring.
Every update also carries a queue window, and since round forty-eight the
card is built out of it. NowPlaying (Rust) and NowPlayingArgs (Kotlin)
hold a queue of at most twenty-one entries — the current item and ten either
side, each with its id, title, artist, album, duration and art key — plus
queueIndex, where the current one sits within that window rather than
within the whole queue, queueBase, where the window starts in the whole
queue, and repeat. src/mobile/media.svelte.js fills all of them from
player.queue, player.qi and player.repeat, and
SoulStreamPlayer.getState() turns them into the media3 playlist, the current
index and the repeat mode. queueBase is what gives an entry a stable
MediaItemData uid as the window slides past it, so a track change is one
media-item transition rather than twenty-one re-labelled rows — and it is half
of the mapping Android Auto will need to turn a tapped queue row back into a
playQueueAt(). repeat crosses so the player's state is not a lie to
whatever reads it — the system's own media controls now, Auto later — and so
media3's view of "is there a next item" agrees with the hasNext the next
button is advertised on. Its numberings are SWAPPED: the app 0 off · 1 ALL ·
2 ONE, media3 0 OFF · 1 ONE · 2 ALL, so SoulStreamPlayer maps across rather
than casting, which would have turned repeat-all into repeat-one. Ten either
side because one covers the card's own prev and next and nothing else, while a
queue list — which is what Auto asks for — wants a screenful plus a scroll
in both directions; and it is a cap, not a length, so a two-thousand-track
queue crosses the bridge as twenty-one entries exactly like a twenty-one-track
one does. Measured at two kilobytes of JSON for twenty-one short titles and
six with long ones and real Android cover paths, against the thirty to eighty
kilobytes of base64 cover that already ride the same call once per record —
and an update is a track change, a transport change or the thirty-second
top-up, never a position tick.
Because it is on no screen, the walk reads it out of logcat:
MediaSessionPlugin.update logs one
media window n=… i=… art=… base=… rep=… at=… line per update under the tag
SoulStream, and the phone walk's queue-window check holds that against
__ss.card — the mirror media.svelte.js keeps of what it last sent, beside
xfernote's — field for field, through a next and a previous. Field for field
is deliberate: a key spelled snake_case where the bridge wants camelCase
arrives as nothing at all on both hops, with no error anywhere, which is why
the art-key count is compared and not just the size and the index. base and
rep are on that line for a sharper version of the same reason: they steer
the media3 playlist and the card's next button, they are on no screen and no
card, and this is the only place on the device either of them can be read
back at all.
Three things in that check are load-bearing and easy to undo by accident. It
arms at queue index one rather than wherever the walk left off, which buys
a row on either side (prev() seeks to zero at the top of a queue and
next() stops at the end), a track that has just started (prev() is a seek
again past five seconds), and the fact that while the queue index is at most
ten nothing is clamped off the front of the window — so the window's index IS
the queue index, which is what lets the logged index be compared against the
queue's directly. It requires each reading to be a FRESH one at both ends, the
push counter up and a new line in the log, because a prev() that lands back
on the row it started from reads exactly like a stale line otherwise. And it
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
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
androidx.media3.session.MediaLibraryService — resolving to MediaService
(auto-browse, auto-library), which is also the guard for a media-session
plugin that compiled and never reached the APK: the service, its filters and
the Auto meta-data live in one library manifest and are lost together, which
this project has already been through once as a start-up
ClassNotFoundException. Printed but never failed, because their dumpsys
formats have never been read back from this phone: whether the service is
foreground, whether our audio focus is in the system's stack, and whether the
becoming-noisy receiver is registered (media-plumbing). Printed and never
failed for a different reason — pm dump prints no application meta-data at
all, so nothing on the phone can be asked about it — the
com.google.android.gms.car.application declaration as tools/auto-prober/
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
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.
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 · · 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
words, and where the desktop already has a word for something it is the
word used here too — Seek, View album artwork, Close artwork viewer. An
icon-only control carries an aria-label; one with visible text is named
by the text it shows.
The nav is a tab set: role="tablist" on the bar, role="tab" and
aria-selected on the five destinations, and the count badge round
seventeen added is part of the tab's own name — "You, 3 transfers",
"Community, 2 unread" — rather than a digit read out before the word it
belongs to. The badge itself is aria-hidden, because the number is in
the name now.
The two sheets are dialogs: role="dialog" aria-modal="true", the player
sheet named for what is playing, the half sheet reading its own first
title back out of the DOM so that twenty-seven kinds of sheet need no list
of names to keep in step. Opening one puts the focus somewhere sensible —
the player sheet on its Close, the half sheet on its grab handle, which is
a real button now ("Close this sheet") and the first dismissal a screen
reader could ever reach; the scrim behind it never was one.
A sheet that is down is inert and aria-hidden. Neither sheet is
unmounted — they are translated off the bottom of the screen, and the half
sheet deliberately keeps its last body rendered through the slide down —
so before this the closed sheets' rows, the hidden mini bar and the nav
under an open sheet were all still in the accessibility tree, and TalkBack
could swipe into every one of them.
There is one live region, .sr-live, mounted for the life of the app and
clipped to a single pixel, because a region that appears with its words
already inside it is announced reliably by nobody; an announcement here is
a change of text in a node that was always there. That is what carries an
error and a notice, so an Undo offer is actually read out — and the Undo
button itself stays reachable, only the sentence beside it is hidden from
the tree so it is not said twice.
Artwork that opens the viewer is a control, not decoration. Artwork that
is decoration carries alt="" and every icon is aria-hidden, so the
tree never reads a filename out. The waveform is role="slider" with a
value and arrow keys where it can seek, and aria-hidden on the mini bar
where it is only a picture of the position. Switches say on or off, choice
chips say pressed, and the two long descriptions and the gap line say
expanded.
The check, so this does not quietly rot: the walk's probe sweeps every
interactive element in the live DOM and counts four things — the ones with
no accessible name (a11yAnon, with a11yWho naming the first three
offenders so one run tells you what to add), the ones whose name is junk
(a11yBad: bare digits, a filename, a lone glyph, an essay), the ones
sitting in a layer that is off-screen but still in the tree (a11yGhost)
and the ones that look chosen without saying so (a11yState). All four
are asserted at zero on Home, the library, a record, a catalog record, the
artist page, You, the taste page, a half sheet, the player sheet and the
viewer — and the anonymous count on a room and on both shapes of prompt
besides, because a room's message box and the prompt's own field are where
the fields this round finally named actually live: assert it only where
they are not and the round guards everything except its own work.
One known exception, left alone on purpose: the per-row ⋯ is a
role="button" inside the row's own <button>, and the mini bar's three
transport buttons sit inside its role="button" the same way. That is
invalid ARIA, and both ends are named so nothing is anonymous; unpicking
it means splitting .trk (one flex row with a whole-row :active) and
rewriting the walk's .trk .r selectors, for no gain a screen reader
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 a Rust string in a red toast.
One translator, src/mobile/trouble.js. say(e) takes what the backend
actually hands up — ClientError's Display impls (io: {0}, protocol: {0},
login failed: INVALIDPASS, user {0} is offline or unknown, transfer rejected: {0}, timed out), the commands' own literals (not connected to Soulseek, no stored credentials, no cache dir, could not reach server: {e}), and rusqlite or std::io through e.to_string() — and answers with a
sentence a person can act on. The rule that matters: the stores keep the raw
string and only the DRAWING is translated. player.error still holds what
Rust said, Diagnostics and the walk still read it, and say() is called at the
one place the toast is written — which is why fifty-nine String(e) sinks were
fixed without touching fifty-nine call sites, and why the sixtieth is covered
too. A message the backend already wrote in English ("The playlist needs a
name") matches no rule and passes through untouched.
The worst one it retires was on the sign-in card: a phone with no DNS drew, in
red under the password box, could not reach server: io: failed to lookup address information: no address associated with hostname. It now says
"Couldn't reach the Soulseek server. Check the connection and try again."
The red toast gained the notice's shape — the sentence, and one action beside
it. Try again when retrying is the answer (a resolve that found no sources,
a library read that failed at launch), Sign in when signedOut() says the
trouble is that nobody is. The action is tied to the exact message it belongs
to (player.retry = { for, fn }), so a later, different error can never
inherit the last one's button.
What each screen says now, where it used to say nothing or worse:
- Tracks — a rejected
library_track_indexdrew "0 tracks · 0:00 · unable to open database file" and then "No tracks match.", which is a failed read dressed up as an empty library. Now: "Couldn't read the library index", the translated reason, and a Try again. "No tracks match" only when the read SUCCEEDED. - A playlist — a rejected
playlist_itemssaid "This playlist is empty." Empty now means empty; failed says so, with a Try again. - A catalog record and an artist page — the catalog's own error is translated, the record's stub offers a Try again, and the artist's stub says "the catalog didn't answer" rather than "had nothing to add" when that is what happened.
- Gaps (Library) — the lookup's
catchwas empty, so the line flipped back to "See what's missing" and could be tapped for ever. It now says "Couldn't reach the catalogue — tap to try again". - Home —
home.svelte.jssetsslot.errorand nothing read it: offline with follows, Home drew empty rails and said nothing. One line under the lead now does, and points at the library below. - Search — the whole network section sat inside
{#if online}with no{:else}, so signed out with a query typed there was no row and no reason, just a gap. Now there is a line and a door to Community. - The lyrics face — a lookup that FAILED was drawn as "No lyrics found for
this track.", and that branch was also the only one of the four with no
⋯button, so Paste lyrics was unreachable exactly when you wanted it. Both are fixed: a failure says so, and both branches carry the⋯. - Transfers —
failed · <rust>on a row isfailed · <sentence>.
Two spinners were given an end. request_room_list is fire-and-forget over
the wire (crates/soulseek-client/src/lib.rs — the client sends room_list and
there is no reply channel and no timeout), so "Asking the server for its
rooms…" could sit there for ever. slsk.roomsWaitMs (12 s) arms a timer in
refreshRooms(), the soulseek:room-list handler clears it, and a server that
never answers gets "The server hasn't sent its room list." and a Try again.
Tracks' own "Reading the index…" has its failure branch above. Everything else
was measured and left alone because it is already bounded: user_info 30 s,
user_interests 20 s, a browse 45 s, a login 15 s, a net search 15 s.
One tap could wipe the library. rescan_library (src-tauri/src/pipeline.rs)
ran prune_missing() FIRST, and prune_missing deletes every track row whose
path does not exists(), then every album left with no tracks. With the root
unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of
trouble — You → Rescan library emptied the whole index, silently, and
returned Ok(()). There is now a guard above the prune: make the root if it is
merely not there yet (a fresh install still works), and refuse with
library-folder-missing: <path> if it cannot be made or read. You catches that
into a row under Library folder with the path and what to do about it, and the
toast says it in English.
What the walk has seen. The round that wrote all of the above was straight
about what it had not proved: six of these fixes shipped with no expectation at
all, because neither loadHome nor loadLyrics was reachable from the __ss
handle the walk drives the app through, and both answer a second ask with
nothing — loadHome is gated on started, loadLyrics on the track's own
key — so no amount of tapping re-runs them. They are driven now: Home's
catalogDown, the Gaps line, a catalog record's stub, the artist page's two
different sentences, a playlist that would not read, the lyrics failed face,
and failed · <sentence> on a transfer row. Each is armed with
__ssMock.fail, asserted both by the words a person reads and by the Rust
string being absent from them, and disarmed by the step that proves the face
heals. The handle grew four names for it and no more — home and loadHome,
lyr and loadLyrics — because a test seam that exports the internals
wholesale stops being a seam. The toast's own Try again is pressed for the
first time too, through You → Rescan library: lib.error is written by
loadLibrary alone, and that row is the only door in the app that reaches it
a second time.
What is still unseen, honestly. Three sites draw a raw backend string, so
the rule above has holes: screens/Browse.svelte — a peer's shares that would
not read, which is a full error face, Try again and all, that was simply missed
— and screens/Import.svelte, twice. SyncRoom.svelte is a fourth, by way of
saveTapSync's catch. Six error faces have no expectation either: Browse's,
Community's "The server did not answer.", Room's "Not in this room.", Album's
"That record is gone.", Playlist's "Playlist not found." and Peer's "Not
compared yet." — all reachable, the first two through the mock's weather and
the rest by pushing a nav entry with a dead id. And of the probe's fields, six
are still read out on every step and asserted on none: accent, xlive, hd,
hwant, navMs and a11yWho. The cheapest real win left in the harness is a
global hd === hwant check on every named step — the history mirror is how the
blank page after a confirmed delete was found — but it would fire during the
first-run reloads and needs its own thinking about the suppress window first.
The downloads in the shade
Transfers is a screen inside the app; the desktop says what a Save is doing with the ring in its title bar, the phone with a badge on the nav. Neither is anywhere to be seen once the app is in the background, which is exactly when a Save is left to run on its own. So a running Save now shows in the shade: how many files are still in flight, the one it is on, a progress bar, and two buttons — Stop all and Open.
Its own channel ("Downloads", IMPORTANCE_LOW, no badge, private on a
locked screen, since a filename is nobody else's business), separate from
Playback so one can be silenced without the other, and created when the app
starts rather than when the first download does — a user who wants the
downloads quiet should not have to wait for one to be noisy first.
Its own service too, and that is the part worth spelling out.
foregroundServiceType is per-service: a download is dataSync, playback
is mediaPlayback. MediaService stops itself the moment the bar empties,
so folding the notice into it would take the card down mid-Save; keeping it
alive for a download instead would leave a mediaPlayback service running
with no session, which Android 14+ treats as a violation. And nothing else
keeps the process alive for a Save with nothing playing — which is exactly
how the first real Save died on 2026-09-13, four tracks in. A dataSync
foreground service that exists while downloads are in flight is the
structural fix, and it can only be its own service. The same promote-first
rule applies: onStartCommand calls startForeground() before it looks at
anything, because Android kills the app about ten seconds after a
foreground start that never did. Android 15 caps a dataSync service at
six cumulative hours a day and kills the app if it is still up when the
callback returns, so onTimeout takes the notice down rather than being
taken down with it; the fetches are the Rust core's own and Transfers still
lists them.
The notice's glyph is the same falcon as the playback card's, diving into the
same tray bar it always had, so the two notices read as one app — a second
copy of the path on purpose, because R.drawable.ic_soulstream_note belongs
to the media-session module's R class and is not visible from here.
The buttons come back as the plugin event action
(addPluginListener("transfer-notice", "action", …)). Stop all runs
cancelAll() in transfers.svelte.js, which is the same cancel(id) a
row's tap calls, once per live row — not a second path to the same thing.
Open brings the activity forward and puts the You tab on Transfers, and
the notice's body does the same. Transfers grew the matching button: Cancel
all while anything is in flight, beside Clear finished.
src/mobile/xfernote.svelte.js is the mirror, and it is driven by a Svelte
effect over the transfer store rather than off the soulseek:download
event. That is deliberate twice over: the effect tracks progress as well as
arrivals, and it means a row written straight into the store drives the
notice exactly as a fetched one does — which is how the walk exercises this
without fetching anything. Posts are throttled to one per 700 ms of changed
content; download progress arrives far faster than anyone reads it, and the
system rate-limits (and drops) notifications posted in a flood.
crates/tauri-plugin-transfer-notice is shaped like the media-session
plugin: build.rs hands its android/ Gradle module to tauri-build,
src/lib.rs registers the Kotlin class and exposes update / clear, and
the Kotlin side is two files — the plugin (arguments in, the action event
out) and TransferService. The app's capability grants
transfer-notice:default; the plugin is registered on every platform so
the permission resolves everywhere, and on the desktop its commands answer
"not here". The tauri-build trap above applies to this plugin as well: the
Android module only reaches the app project when
TAURI_ANDROID_PROJECT_PATH is set, which is why adding it also touched
src-tauri/build.rs.
Performance
tests/mobile/perf.mjs logs the frames the WebView produces through
scripted scenes (a long scroll, Home's scroll, a record push, the player
opening, idle playback) under CSS variants injected live, so an effect's
cost is measured rather than guessed. On the Pixel 6a the frosted chrome —
backdrop-filter on the nav, the mini bar and the glass bar — turned
Home's scroll from 60 Hz to 30 Hz (16 of 129 frames over 32 ms; 0 without)
and dropped every other frame of the player's opening (12 → 0). The
ambience blur, the hero's blur and the entrance springs cost nothing
measurable. So the phone's chrome is solid by default and the blur is a
switch (You → Appearance → Glass blur). Re-run the probe before adding any
backdrop blur to the phone; --quick runs the base variant only, and the
app must be on the phone's screen.
The launch itself had never been measured. The bundle the phone loads is
mobile.html and three chunks: its own (about 300 KB), the shared one
(about 130 KB) and a small third (about 27 KB) — roughly 460 KB of JS and
57 KB of CSS, all of it eager. The static graph from src/mobile-main.js
outward is 64 modules and contains no dynamic import at all, so there is one
chunk and the phone evaluates every byte of it at every launch. Importing
that graph in headless Chrome costs 11.6 ms at full speed and 59.2 ms at a
6× CPU throttle.
The desktop is not riding along, which was checked rather than assumed: of
state.svelte.js's 44 long string literals exactly four appear in the
chunks the phone loads, and all four are event names it legitimately uses.
heroGpu.js, trace.js, ImportWindow.svelte, TitleBar and
DebugOverlay contribute nothing; there is no shader source anywhere in the
bundle. The one passenger is the dev mock library
(src/lib/mockLibrary.js, about 32 KB of the shared chunk), which
src/lib/api.js and src/lib/events.js import unconditionally because
mockInvoke has to answer synchronously. It costs 6.6 ms of every launch at
6× throttle, only 1.4 ms of which is its top-level work — the rest is parse
and function definition, which only removing it from the bundle can recover.
import.meta.env.DEV cannot do that, because the mock walk builds
dist-smoke/ with a plain production vite build; getting it out needs a
build-mode flag the walk itself would have to pass. Named here rather than
taken.
The boot chain was the expensive half. It is 26 commands the census can see,
plus two plugin listeners that register through @tauri-apps directly and go
past it. Until this round its critical path — the longest chain where each
call was issued only after the one before it had answered — was ten deep,
because listen is an invoke like any other: loadLibrary asked for five
independent SQLite reads one after another, bindSlsk registered eight
listeners one after another, and watchLibrary and the player's bindEvents
each did two. They go out in one round now, and the difference is countable
rather than felt: ten of those commands used to leave in the shell's own
first turn and twenty-three do now, and the critical path is three instead
of ten. Measured in a rig that runs the real store modules —
both trees, the same onMount script — with the bridge instrumented and the
backend's work modelled behind the library's single conn: Mutex<Connection>:
the same commands both ways, and lib.loaded 85 ms → 53 at a 6 ms bridge
round trip, 105 → 57 at 12 ms, 148 → 62 at 20 ms. Those are a desktop rig's
milliseconds with the round trip swept rather than measured; the structural
half holds anywhere, and the phone's own numbers arrive the first time the
walk prints bootMs and bootLibMs.
Three things were looked at and deliberately left alone. Home's per-artist
catalog walk is the deepest chain in the app — catalog_artist →
catalog_artist_identity → catalog_related, serially, per followed artist
— and that is the pacing, not an oversight: crates/metadata/src/lib.rs
holds a Deezer budget of 45 requests per 5 s and records the MusicBrainz
identity chain at 4–6 requests behind a mandatory 1 req/s gate, 3.7–8.5 s
per artist. The follows are read twice at launch (once by loadLibrary,
once by loadHome), and the obvious guard does not work, because Home's
effect calls loadHome(true) the first time the follows land — one small
local read, left for a round that can do it properly. And lazy-loading the
one-tab screens (Diagnostics, Import, Browse, Peer, Room, Thread and the
importer) is 9% of the source against a real risk of a blank pushed frame,
so it is worth about 6 ms at 6× throttle and is not worth a dynamic import
yet.
The instrument is src/lib/boot.js: one counter every invoke and every
listen passes, sealed at the first frame the shell paints and never
re-opened, so a probe landing a second and a half later still reads the
launch. The mock walk holds a budget against it at the Home step and both
walks print bootCalls, bootSync, bootMs and bootLibMs on every step.
bootSync is the clause with no clock in it: it is how many of the boot
commands left in the shell's own turn, so a chain put back in a queue can
only fall under its floor, whatever the machine is doing. Three things the
instrument does not do. It counts the app's own traffic, so the media card's
and the transfer notice's addPluginListener are invisible to it. It counts
commands, not bytes, so a later round that adds an import to a shared file
can move modules between chunks with nothing failing — that gap is open. And
the mock's exact count depends on how much of the first screen has asked for
its covers by the time the second frame lands, so the call ceiling is set to
catch a regression in kind (a per-artist walk or an index pulled to boot)
rather than a handful of calls.
The audio side had its own lag: dumpsys media.audio_flinger showed the
track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer
above moved it to the normal mixer; the phone walk samples the underrun
counters before and after and fails if they rose.
The long session
Every walk is minutes long; a phone runs the app for hours, and nothing in the port had ever been looked at for a leak. It has now, by driving the real bundle in headless Chrome for lap after lap of the whole app — five tabs, the Library, a record, playback, the mini bar, all three faces of the sheet, the artwork viewer, a row's half sheet, an artist page — and counting what the app was still holding at the start and at the end.
Listeners and timers do not leak, and the obvious counter says they do.
With a registry installed at document start — an investigation, not what
ships — add minus remove climbs 23 → 119 over eight laps, about twelve a
lap, almost all of them scroll. None of it is a leak: {#key nav.seq}
replaces the .screen element on every navigation, so each new screen gets
its own handler and the old element is dropped whole. Counting only what is
still ATTACHED — window, document, or an element that is still
connected — gives 29 at the first lap and 29 at the eighth, key for key.
Intervals are the same story: two at boot (the media card's 30 s, the
player's 10 s session writer), three from the first lap on, and three at the
last; Lane and Diagnostics both clear theirs in their effect teardown,
and every bind*() in src/mobile/ is guarded by a module-level bound
flag, so no Tauri subscription is ever registered twice.
What the walk ships is narrower than that, and says so. A script
registered with Page.addScriptToEvaluateOnNewDocument does not survive the
first-run block's reloads — the re-attach opens a new DevTools session — so
the registry the walk installs goes in as a STEP, after everything else, and
can only see what is registered from that moment on. Its absolute counts are
therefore small and depend on how much ran first: tallyEars reads 2 at the
end of the full mock walk and 8 from a settled boot, tallyTicks 1 in both
(the mock's own playback ticker). Those numbers are not a census of the app
and are not asserted as one. What is asserted is the DELTA across the laps,
which is where a per-navigation leak shows, and that was proved by planting
one: a listener and an interval stranded once a lap take grewEars 0 → 9
and grewTicks 0 → 9, and the step fails.
The thumbnail map did leak, and is bounded now. src/mobile/art.svelte.js
kept every cover it had ever fetched for the WebView's lifetime: one browse
of a 114-cover library took it from 5 keys to 114 and gave nothing back, and
a catalog crawl adds to the same map without limit. A 360 px cover comes
back from album_art as base64 JPEG — about 63 KB of string, measured
against this machine's own thumbnail cache — so that browse alone is roughly
7 MB retained. The bound is the desktop's, by its own numbers:
ART_THUMB_CACHE_MAX 512 and ART_THUMB_CACHE_MAX_CHARS 16 MiB from
src/lib/state.svelte.js, trimmed oldest first. Two rules the desktop does
not need are added, because the desktop's callers ask through getArt(path)
while the phone's templates INDEX the map (art[path] in Cover, Release,
Sheet, Viewer, Artist, Discography, Home and the media card): the entry that
has just arrived is never dropped, and neither is one a connected <img> is
painting — Cover's visible action re-arms only when its path PROP
changes, so an evicted cover that is still mounted would stay a disc icon
for good. The few places that paint a cover as a background-image rather
than an <img> — the ground, the sheet's wash, the artist hero — are
covered twice over: each of them reads art[path] inside an effect that
calls wantArt, so an eviction under one re-fetches on its own. The cache
is therefore allowed to sit over its cap for as long as one screen needs all
of it, and comes down the moment that screen goes. Built with the cap forced
to 8 and scrolled through eighteen screens of a 140-card Library grid, the
map sat at 61 — every one of them spared for being painted — and not one
cover on screen lost its image at any point; the cards that had scrolled
away repaint cold and fill as they come back, which is what a cold first
visit already looks like. At the shipped cap of 512 a whole library walk
runs the trim zero times and the census is identical either way: on Kyle's
phone the change is inert, and it is the crawl past 512 covers that it
exists for. The accent table gets the same cap — an accent is a pure
function of its cover, so dropping one costs a recompute and nothing else.
Playlist covers. pls.covers is keyed id:revision, and an edit bumps
the revision, so every edit stranded one 360 px data URL under a key nothing
could ever read again. The new cover now drops that playlist's other
revisions as it lands — no cap and no policy, just the keys that are
unreachable by construction.
Three things are unbounded on purpose. catalog.svelte.js's ten maps are
cached for the session by design, and evicting from them is not safe the way
the art map is: Artist.svelte calls lookupArtist inside untrack, so a
page whose entry went missing under it would never look it up again.
lib.svelte.js's tracksCache is unbounded but finite — it is bounded by
the library. And player.svelte.js's scheduleRecents leaves a 47-second
timeout per track start, which self-drains and is the desktop's own shape;
coalescing the two callers into one slot would cancel one of them, so it is
watched under a ceiling instead of changed.
The walk measures all of it. Every step prints tallyArt, tallyChars,
tallyAcc, tallyPend, tallyCat, tallyQueue, tallyXfer and
tallyNodes from the app itself (artTally() on the __ss handle, because
the map and its side tables are module-private), and tallyEars,
tallyTimers, tallyTicks plus the grew* deltas from a registry the
session block installs in the page. The registry is installed by a STEP, not
by Page.addScriptToEvaluateOnNewDocument: the first-run block reloads three
times and the re-attach opens a new DevTools session, so a script registered
on the old one would be gone. The block's last step takes it out again —
EventTarget and the four timer functions restored, __ssCount and the
baseline deleted — so nothing appended after it inherits a patched platform,
and every step after the block reads −1 for those fields, as does the whole
phone walk, where it is never installed at all. The assertion is a threshold
with headroom, not a stopwatch — the listener and interval counts are held
flat, the art map is held under its cap, and tallyTimers is a ceiling
rather than a delta because the recents window keeps a handful pending
during a walk this fast. Across eleven laps of the block as it ships, driven
against a real build of this tree, the listeners and the intervals moved by
0, the timers stood at 9 against a ceiling of 32, the art map grew 17
against a headroom of 80 and the catalog 5 against 24, and the DOM ended 10
nodes smaller than it started.
Prerequisites (home machine, done 2026-09-12)
- Android SDK at
%LOCALAPPDATA%\Android\Sdk: command-line tools, platform-tools, platforms 34–36, build-tools 35/36, NDK 28.2.13676358, cmake 3.31.6. Installed withsdkmanageralone — no Android Studio. - User environment:
ANDROID_HOME,NDK_HOME,ANDROID_NDK_HOME,JAVA_HOME=C:\Program Files\Java\jdk-17; PATH has platform-tools and cmake. (A bash session may not see them; the scripts below set their own.) - Rust targets:
aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android. - The Tauri CLI here is npm's:
npx tauri …(there is nocargo tauri). - Windows Developer Mode (Settings → System → For developers). Tauri
symlinks the built library into
gen/android/app/src/main/jniLibs; Windows refuses symlinks without it. Until it is on, use the detour below. - No emulator: virtualization is off in this machine's firmware (MSI B550 Tomahawk: OC → Advanced CPU Configuration → SVM Mode) and Windows Hypervisor Platform is off. Builds go to a real phone over USB.
Build and run
npx tauri android build --debug --target aarch64 --apk
With Developer Mode off, the CLI builds the Rust side and then fails at the symlink. The detour, scripted:
bash src-tauri/android/deploy.sh # vite → cargo → strip → gradle → adb install → launch → screenshot
bash src-tauri/android/build.sh # the build alone; APK path printed at the end
What the scripts do, by hand:
# 0. the frontend, then the library — with tauri's custom-protocol feature ON.
# Without it the build embeds the dev URL (http://localhost:1421) instead of
# dist/, and the phone shows "Failed to request http://localhost:1421/mobile.html".
npm run build
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol
# (with CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER / CC_ / CXX_ / AR_aarch64_linux_android
# pointing at the NDK's windows-x86_64/bin aarch64-linux-android26-clang(.cmd) wrappers)
# 1. strip the library's debug info (607 MB → 50 MB; the debug packaging keeps symbols)
cp target/aarch64-linux-android/debug/libapp_lib.so src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/
"%LOCALAPPDATA%\Android\Sdk\ndk\28.2.13676358\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe" --strip-debug src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libapp_lib.so
# 1b. the app's own resources, over the generated project's (the launcher icon)
cp -r src-tauri/android/res/. src-tauri/gen/android/app/src/main/res/
# 2. package with Gradle, skipping the task that would redo the symlink
cd src-tauri/gen/android && ./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug
APK: src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk.
A frontend-only change still needs the cargo step: dist/ is embedded in
the library at compile time (the app crate alone recompiles, ~1 min).
Phone (USB debugging on, prompt accepted):
adb devices
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
adb shell am start -n com.soulstream.app/.MainActivity
adb logcat --pid=$(adb shell pidof com.soulstream.app) # Rust logs land under RustStdoutStderr / app_lib::*
adb exec-out screencap -p > shot.png
adb shell input tap X Y # drive it: 1080×2400 px, 2.625 px per dp
adb shell input keyevent KEYCODE_BACK
Music for a test library goes in as the app (no root needed on a debug build):
adb push "F:\Music\SoulSeek Music\<Artist>\<Album>" /data/local/tmp/ssm/
adb shell "chmod -R a+rX /data/local/tmp/ssm; run-as com.soulstream.app sh -c 'mkdir -p \"Library/<Artist>\" && cp -r \"/data/local/tmp/ssm/<Album>\" \"Library/<Artist>/\"'"
adb shell am force-stop com.soulstream.app # the scan runs at launch
Iterating without cargo (the dev flavour)
Every embedded build pays ~2 min of cargo for a CSS change, because dist/
is compiled into the library. The dev flavour skips that: the same detour
build without tauri/custom-protocol, so the library embeds the dev URL
(http://localhost:1421), and adb reverse tcp:1421 tcp:1421 makes the
phone's localhost:1421 this machine's Vite dev server over the USB cable —
no LAN address, no firewall hole, hot reload on the phone. Cargo runs only
when Rust changes. Both flavours share target/: a switch recompiles tauri
and the app crate once (~3 min, the leaf crates stay fresh).
bash src-tauri/android/dev.sh # build the dev library (when Rust changed), install, tunnel, launch
npm run dev # or the Browser pane's "frontend" config — the phone loads from it
bash src-tauri/android/dev.sh --no-build # after a reconnect: reinstall/tunnel/launch without cargo
bash src-tauri/android/deploy.sh # the embedded build again — what the phone keeps when the cable goes
The dev APK needs this machine and the cable; put the embedded build back
before the phone leaves. The toolchain env both scripts share is
src-tauri/android/env.sh. Verified 2026-09-13: a shell.css edit reached
the phone in under a second. The page's origin stays http://tauri.localhost
— Tauri proxies the dev server through its own scheme on mobile — so the
harness and the app see one origin in both flavours.
The release build
Every APK in this port is assembleArm64Debug — every deploy.sh, every
dev.sh, every walk, every build on Kyle's phone. The generated project turns
R8 on for release and only for release: in
src-tauri/gen/android/app/build.gradle.kts the debug type is
isMinifyEnabled = false and names no proguard files, and the release type is
isMinifyEnabled = true plus proguard-android-optimize.txt and every
**/*.pro it finds in the app directory. R8 is the one thing that can break
this app in a way no build can see.
Tauri's Android plugins are reached by NAME, at three layers, none of which R8 can trace:
register_android_plugin("com.soulstream.storage", "StoragePlugin")becomes the stringcom/soulstream/storage/StoragePluginand goes toFindClass; the instance is constructed through the JNI signature(Landroid/app/Activity;)V.PluginHandle.indexMethods()walks the class and its superclasses withgetDeclaredMethods()and files every@Commandundercommands[method.name], every@ActivityCallbackunderstartActivityCallbackMethods[method.name].invoke()looks up the name Rust or the WebView asked for and throwsNo command pickFolder found for plugin …when it is not there.- Jackson fills every
@InvokeArgclass FIELD by field.
So a minified build compiles, installs, launches and shows Home — and then a tap on Library folder, or the card's next button, does nothing at all. Four plugin modules expose eleven commands, four activity callbacks and four argument classes that way.
bash src-tauri/android/build.sh --release # or SS_ANDROID_RELEASE=1 bash src-tauri/android/build.sh
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk
With no argument build.sh is what it has always been — the same cargo line,
the same assembleArm64Debug -x rustBuildArm64Debug, the same APK path — so
deploy.sh, dev.sh and ship.sh cannot reach the release path by accident.
The flag changes four things and nothing else: --release and
--features tauri/devtools on the cargo line, the release profile's .so,
assembleArm64Release -x rustBuildArm64Release, and a sign step at the end.
Where the keep rules live, and why in two places. Each plugin carries its
own consumer-rules.pro in crates/tauri-plugin-*/android, registered by one
consumerProguardFiles("consumer-rules.pro") line in its build.gradle.kts —
the same shape tauri-android and tauri-plugin-dialog already use in this
build. (tauri-plugin-opener ships a consumer-rules.pro and never registers
it; the file is inert. That is the mistake this line exists to avoid.) Consumer rules travel with the plugin, so a regenerated
gen/android keeps them, and they are inert in the debug APK because a
consumer file only does anything when the consuming app minifies. What belongs
to no single plugin is in src-tauri/android/proguard-soulstream.pro, which
build.sh --release copies into src-tauri/gen/android/app/ for the
fileTree(".") glob to pick up — it cannot simply live there, because gen/
is generated and gitignored and tauri android init rewrites it. That file
holds three things: the runtime annotation attributes, a -dontwarn pair for
two java.beans classes jackson-databind references and Android does not
have (R8 treats a missing class as a build error), and a keep for four
@Command methods declared on app.tauri.plugin.Plugin itself —
registerListener, removeListener, checkPermissions, requestPermissions.
Tauri's own rule matches classes annotated @TauriPlugin and the members they
declare; the base class carries no such annotation, so nothing names those
four, and registerListener is what the lock-screen card's buttons and the
download notice's Cancel/Open ride on. That last rule is a precaution, not a
diagnosed bug — it is six lines, and it removes the question.
What already keeps the wry bridge, and must not be thrown away. The
**/*.pro in that glob is recursive, and the file it reaches that nobody
thinks about is
app/src/main/java/com/soulstream/app/generated/proguard-wry.pro, written
beside the generated Kotlin by wry's own build script. It is what keeps
Rust's native methods, WryActivity.setWebView/getVersion/getId,
Ipc's @JavascriptInterface methods and RustWebView.evalScript — the
entire JNI bridge between the Rust core and the WebView, all of it reached by
string from Rust. proguard-tauri.pro beside it keeps
TauriActivity.getPluginManager(). Neither is ours and neither is in git;
both are regenerated. Narrow that glob to a named list and the app stops at a
black screen. The four plugins are the gap because they are the only
name-reached surface nobody else wrote a rule for.
What is different about a release build, beyond minification.
usesCleartextTrafficflips to"false"(debug sets the placeholder to"true"). The page's own origin ishttp://tauri.localhost, which Tauri serves itself, so this should not bite — but it is the first thing to check if the WebView comes up blank.- Not debuggable, so
run-as com.soulstream.appis gone: the documented way of pushing test music into the app's private Library folder does not work on a release build. Point it at a folder in shared storage instead. - DevTools exist only because
build.sh --releasepasses--features tauri/devtools; wry compilessetWebContentsDebuggingEnabledundercfg(any(debug_assertions, feature = "devtools")). Without it there is nowebview_devtools_remote_<pid>socket andsmoke.mjs --phonehas nothing to attach to. - The APK Gradle produces is
app-arm64-release-unsigned.apk— there is nosigningConfigsblock in the generated project.build.sh --releasezipaligns it and signs it with~/.android/debug.keystore(aliasandroiddebugkey, passwordandroid), the same key the debug builds use, soadb install -rreplaces the app in place. Sign it with any other key and that install fails; the obvious next move — uninstall, then install — deletes the app's data, which on Kyle's phone means his settings, his account and his queue. This is a test build, not a distributable one. cargo build --releasefills a secondtarget/aarch64-linux-android/release/tree; the debug artifacts are untouched, but that cache roughly doubles.- Both build types package
app/src/main/jniLibs/arm64-v8a/libapp_lib.so, and a release run leaves the release library sitting there.deploy.shcopies over it unconditionally, so the next debug deploy is correct — but a Gradle run by hand in between would package the release library into a debug APK. - If lint fails on something unrelated, run Gradle by hand with
-x lintVitalArm64Release; AGP 8 splits that into an analyze task and a report task, so-x lintVitalAnalyzeArm64Releasemay be the one that actually has to go. It did not come up:lintVitalX86_64Releasepassed on the build described below, so the exclusion is advice for a failure that has not happened rather than one that has.
What to smoke-test on a release build, and only on a release build. The walks do not cover this: the pipeline reinstalls the debug APK constantly, and what minification breaks is exactly the part that is hands rather than CDP — pickers, notifications and a share sheet. Every plugin command, one at a time:
- storage (six commands, four activity callbacks) — pick a library folder
(You → Library folder) and check the picked path comes back; the all-files
access row and the system screen its button opens; pick an image (a playlist
cover); pick a file (a playlist import); export a playlist, which is
createDocumentand has no JS-facing command at all — it is called from Rust, so it is the one a hand test forgets. - media-session — play something and watch the card appear (
update); its prev, play, next and stop, which arrive throughregisterListener; stop playing and watch it go (clear). - transfer-notice — start a Save and watch the notice appear (
update); Cancel and Open on the notice, alsoregisterListener; let it finish and watch it go (clear). - share — share plain text to SoulStream from another app (
pendingShare).
A command that has been renamed answers No command <name> found for plugin <class>, which lands in logcat rather than on screen:
adb logcat --pid=$(adb shell pidof com.soulstream.app).
The launcher icon
Until this round the phone wore tauri's logo. tauri android init writes
the generated project's res/ from its own template and never reads
src-tauri/icons/android/ — which has carried the falcon since 50afa6a
— and the detour build runs cargo and Gradle directly, so nothing ever
copied it across. The two dominant colours in
gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png were #24c8db
and #ffc131: tauri's brand cyan and tauri's brand amber.
src-tauri/android/res/ is the app module's res, tracked, and build.sh and
dev.sh copy the whole tree into the generated project before Gradle runs.
It carries:
mipmap-anydpi-v26/ic_launcher.xmlandic_launcher_round.xml— the adaptive icon: a foreground, a background, and a monochrome layer, which is what Android 13's themed icons need and what tauri's generator has never made;mipmap-{m,h,xh,xxh,xxxh}dpi/—ic_launcher_foreground.pngandic_launcher_monochrome.png(108–432 px) from the kit'sandroid/adaptive/res/, and the legacyic_launcher.png/ic_launcher_round.png(48–192 px) from itsandroid/legacy/;drawable/ic_launcher_ground.xml— the adaptive background: concept 04's gradient, the desktop icon's own ground, over the whole 108-unit layer;values/ic_launcher_background.xml—#FF05080B, the brand's night. Not the background any more, and kept for its filename (below).
Three things about it are not obvious.
The kit's own adaptive XML does not compile here. Its
android/adaptive/res/mipmap-anydpi-v26/*.xml points every layer at
@drawable/…, and the kit ships those layers as PNGs under mipmap-* with
no drawable-* directory anywhere. That is worse than a missing resource:
the generated project still carries Android Studio's template vectors at
drawable/ic_launcher_background.xml and
drawable-v24/ic_launcher_foreground.xml, so two of the three references
resolve — to the green robot — and only @drawable/ic_launcher_monochrome
fails the build. The overlay's XML says @mipmap/ for the two image layers
and a @drawable/ of our own for the background instead.
The background is a vector drawable, not a colour and not five PNGs.
drawable/ic_launcher_ground.xml carries the desktop icon's own ground —
concept 04's navy surface and the cyan glow off its top-left — mapped onto the
whole 108-unit layer, every number the kit's src-tauri/icons/icon.svg
multiplied by 108/1024. The flat #05080B was three quarters of what a masked
tile shows (the falcon covers about a quarter of it), and the glow survives
every mask: the visible top-left corner goes from #05080B to #0E3543. The
rx="232" on the kit's rects is not carried over; on the phone the launcher's
mask does that job.
A vector rather than a PNG set because this artwork happens to fit one.
Android's android:type="radial" is a circle — centre and radius, no
transform of its own — and the kit's glow is
translate(270 220) rotate(43) scale(660), a uniform scale, so the
ellipse is a circle and the rotation is a no-op. Had that scale been two
numbers, no vector could have said it and this would be five rendered PNGs.
The other cheap route, a <layer-list> of two <shape>s, is out on the
linear axis alone: the concept's is 51.1° off horizontal, and a shape
drawable's android:angle "must be a multiple of 45", with three evenly
spaced stops at most. Folding stop-opacity into 8-bit alpha (0.18 → 2E,
0.05 → 0D) costs at most one level of 255 against the SVG's float composite.
And a vector reaches sizes no PNG here does: the media card draws this same
drawable at 512 px (below) and the Android 12+ splash slot is 240 dp, about
630 px on the Pixel, both past the 432 px an xxxhdpi PNG tops out at.
What a vector cannot do is dither. Both forms are 8-bit and quantise
identically — 122 colour steps down the diagonal at 432 px, longest flat run
43 px, over a total range of R 12 / G 49 / B 55 levels — so the form does not
cause banding, but only a PNG could have been dithered out of it. If the
phone's OLED contours, the answer is a dithered five-density set behind this
same @drawable/ic_launcher_ground name: a file per density, and no XML
moves.
The colour file stays, and its filename is the whole reason. Nothing
points at values/ic_launcher_background.xml since the ground became a
drawable — values-v31/themes.xml writes the splash colour as a literal on
purpose — and deleting it would build. It is kept because
src-tauri/icons/android/values/ic_launcher_background.xml declares the same
resource name (#fff), and two files in one values/ directory declaring
one colour is a hard aapt2 error — resource 'color/ic_launcher_background' has a conflicting value. Sharing the filename means the overlay overwrites
it rather than colliding with it, if a future npx tauri android init ever
copies it in.
That leaves src-tauri/icons/android/ a second, stale copy of these same
resource names: white background, no monochrome, hdpi at 49×49. Nothing on
Android reads it while the build is the detour — but npx tauri icon writes
it, and it should be brought to the kit before anyone builds through the CLI.
Checking it
verify-icon.sh opens the APK build.sh has just made and reads the
resources back out. It checks the overlay itself first, before the APK is
opened — a <monochrome> layer that a later edit drops is a failure, and so is
a ground flattened back to one colour — and then reads the APK, debug and
release differently, because AGP hands them to aapt2 differently:
- debug — the packaging default for a debuggable build type is
--no-crunch, so every entry is the file on disk. Bothres/mipmap-anydpi-v26/*.xmlmust be in the APK, and all four PNG names at all five densities must be byte for byte the overlay's. - release (
--crunched) — aapt2 re-encodes every PNG, and AGP's resource optimization (OptimizeResourcesTask→SHORTEN_RESOURCE_PATHS) renames the lot: an APK optimized here had nores/mipmap-…entry left at all, onlyres/Ag.png. Neither the bytes nor the names survive, so the check asksaapt2 dump resourceswhat each name points at and compares the pixel dimensions of whatever that is — 72×72 at hdpi still separates the kit's legacy icon from tauri's 49×49. If the resource names have been collapsed as well (that comes with resource shrinking, which this project does not turn on), it says the icon is unchecked rather than claiming a failure it cannot see.
And in both, the ground. This one is not a name or a byte but a
resolution: <background> is a reference, so the check asks aapt2 dump resources what mipmap/ic_launcher is at anydpi, reads that file's
<background> id, maps the id back through the table, and requires
drawable/ic_launcher_ground; then it follows the two fillColor references
the aapt:attr blocks became and requires concept 04's six stops in them.
That is the check that catches a stale APK as well as a lost overlay —
build.sh pipes Gradle through | tail -20 || true and then runs
ls -la "$APK", so a Gradle run that failed leaves the previous build's file
exactly where the next step looks for it, with every PNG in it still the
falcon's. Without aapt2 on the machine this half says NOT CHECKED and the
rest of the script still runs.
Both build.sh and dev.sh call it, so no APK this repo makes can quietly go
back to the old artwork. It cost about a second and a half on the 107 MB debug
APK before the ground was added; the ground is one dump resources and three
dump xmltree calls on top of that, measured at 0.3 s against that same real
APK — aapt2 reads resources.arsc and the entries it names, never the whole
file, so the cost does not grow with the APK. The release branch reuses the
same table rather than asking aapt2 for it twice.
What it cannot check is what the icon looks like. deploy.sh's px-1.png is
a screenshot of the app five seconds after launch; the launcher is not in it.
The one thing that may now differ there is the Android 12+ system splash,
which is drawn from the launcher icon — if a cold start ever runs past those
five seconds, that splash is the falcon rather than tauri's mark. Whether
the ground is under it there is the platform's call, and this round very
likely changes the answer. SplashscreenContentDrawer.processAdaptiveIcon
drops an adaptive icon's background and draws the foreground alone —
enlarged, since it is then filling the slot by itself — only when that
background is not complex and reads as the same colour as
windowSplashScreenBackground. A background with more than one colour in it
is kept and the whole icon is drawn, contrast or no contrast. Round 44's flat
#FF05080B is exactly the colour round 45's values-v31/themes.xml writes
there, so it was dropped and the falcon drew large on the flat night; a
gradient is not one colour, so the likely new answer is the whole icon —
this ground, under a falcon no longer enlarged to fill the slot. Likely,
not proven: it is a screenshot, and the docs' own one-line version of that
rule would have predicted the other way.
Everything about how it looks can only be judged from the phone, and the
gradient ground adds to that list rather than shortening it.
verify-icon.sh proves the adaptive icon's <background> resolves to a
drawable carrying concept 04's six stops and its geometry; it cannot see a
pixel. So: the home screen under the launcher's mask, the app
drawer, recents, another app's share sheet — reading for banding on the
OLED above all, since that is the one thing the vector form cannot dither its
way out of, then the glow's weight under the Pixel's circle and how the falcon
sits on a lit ground rather than a flat one — and then Wallpaper & style →
Themed icons on, which swaps both layers for the monochrome one over a
background the wallpaper picks: the one slot this ground never reaches, on
the list so that a round about the background can be seen not to have moved
it.
One consequence of this round is reachable without the launcher, and it
is the quickest of them to check. MediaHub.appIcon() in the media-session
plugin draws whatever getApplicationIcon() returns into a 512 px bitmap and
hands it to the lock-screen card as its large icon, for every track whose
cover has not been decoded yet — so the card and the shade carry this ground
now as well. Play a track with no cover and look at the card. Nothing else in
the app reads the launcher icon, and the walk never reads the card's icon —
only its android.title and the session's playback state — so no step
changes meaning either way.
Verifying
node tests/mobile/smoke.mjs # headless Chrome + the mock library
node tests/mobile/smoke.mjs --phone --album "Spiderman" # the real app on the connected phone
The mock walk builds dist-smoke/, switches the mock library on
(src/lib/mockLibrary.js: 140 records, some without art on purpose, and a
fake network behind the same switch — a stored account that signs in at
boot, rooms with a few lines in them, a catalog with one artist, a search
answered by five peers, a stream that arrives over twelve seconds with its
waveform growing; backend events go over the mock's own bus, which
src/lib/events.js puts in front of Tauri's listen), drives Home → a long
press → Search → Library and its tabs → a record → its sheets → the player's
three faces → the back gesture → an artist page → Community → a room → a
catalog search → that artist's whole discography on a page of its own (All
in front of six categories, a year header per year, the editions opened in
place, a name order dropping the grouping, and a 140-record artist whose
grid caps at twelve and whose page shows sixty with the rest a button away)
→ a song through the resolver → the lane rising in the sheet →
a catalog record, its Save and the Transfers burst (and the shade's notice:
posted with a count while rows are in flight, gone when they are not) →
Gaps → the playlist
shelf and a page with every row kind (a local row, the stream row through
the resolver, the filter, a removal and its undo, a row dragged into place
and dragged back, a drag refused while the filter is on, a press released
without moving still opening the row's sheet, a drop the backend rejects
snapping back, unpin, rename, a new playlist, Add to playlist from a
record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, pasted track
links read a page each, a folder of audio files off the phone, a playlist
file through the phone's own document picker — with that picker cancelled
and a file it can hand no path for refused — and a share from another
app) → the playlist repair (an unknown row relinked from the
library) → the artwork viewer → a queue row's sheet and a queue row
dragged into place → a catalog song's,
a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
of theirs streamed → a buddy's sheet → You (every switch and chip saved
and read back from the config) → recent searches and a spelling
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
record with a long sheet over it, an artist page, and the side insets faked,
all at 915×411) and back → and last, on a reload of its own, a phone that
has just been installed (the first-run sheet and its two cards, its
Soulseek door, the notes on Home, on both Library tabs and on Search, the
preview from You, a folder picked from the tour and the note losing the row
it answered), then back to the provisioned device, and after that the
playlist export door three ways (a file written where the sheet pointed, a
dismissed sheet that says nothing at all, and a refusal read back as a
sentence), asserts what each step should show — the Home
step holds the launch to a budget as well, reading off __ss.boot how many
commands the app issued before its first frame, how many of those left in the
shell's own turn, how long it took, and requiring round 12's art prewarm to
still be absent from them — screenshots every step into tests/mobile/out/, and fails on
any exception or console error. Near the end of the mock walk is the
weather: a library index that will not read and its Try again, the client taken
offline, a refused password and a server that cannot be reached, Search with
nowhere to search, a room list that never arrives, and a rescan that refuses
because the folder is gone — then all of it cleared and the app left as it was
found. After the weather come the faces that failure actually draws, each one
armed by the mock and read back by its own words: Home with every follow's
catalog page refused, the Gaps line and its second tap, a catalog record's stub
and its Try again, an artist page whose lookup failed beside one that was
answered with nothing, a playlist that would not read, the lyrics face on a
lookup that never came back, a transfer that was refused, and the library
toast's own Try again pressed for the first time — then all of that disarmed
too, and asserted disarmed. The mock answers every command the phone
uses; one it does not know is named in the report ([mock] no mock for X)
rather than surfacing as a TypeError toast, console warnings carry their top
frames (the smoke bundle is built unminified for that), and every probe
carries the history mirror — the entry's depth against the layers the app
says the back gesture can peel (hd/hwant; a drift is how the blank
page after a confirmed delete was found). The mock is also the weather:
__ssMock.fail(cmd, message) arms a command to reject with that exact string,
answer(cmd, value) arms one to resolve with a value the walk names (an empty
list, a null page), offline(on) takes the client down — everything the
backend funnels through with_client() answers "not connected to Soulseek",
the two connects answer the server string, and soulseek:state goes out — and
clear() puts all of it back. They reject with a plain string because that is
what Tauri rejects with. Fourteen mock steps drive them, and the phone walk carries
two safe steps of its own (trouble-words: eleven real backend strings through
__ss.say on the device, every output asserted free of Rust; and
trouble-xfer: one already-failed transfer row written into the store and
taken away again — nothing fetched, and a terminal row is not in flight, so
nothing reaches the shade). Fifteen more mock steps drive the failure faces
themselves, which are described under When things go wrong. A mock step can
also set the
viewport — { view: [915, 411] } sends Emulation.setDeviceMetricsOverride
through the same client, which is how the walk turns the phone over and back;
the phone walk refuses one, because a real device's orientation is not the
harness's to set.
The device turns itself over instead, in a block of the phone walk's own,
after background and before the network — because a step list cannot do
either of the two things this needs. It reads
settings get system accelerometer_rotation and user_rotation FIRST and
latches the pair; only then does it put auto-rotate off and user_rotation
to 1 (a retry at 3 for a device whose natural orientation is landscape), and
it polls for the turn rather than waiting a fixed time. Then it asserts what
only a device can answer: screen.orientation.angle is 90 or 270 — an
Emulation.setDeviceMetricsOverride without screenOrientation leaves the
angle at 0, so the mock physically cannot fake this, and it is the one clean
"a real phone did this" clause — that the WebView RESIZED as well (a separate
clause, because a WebView that never got the configuration change looks
identical to a display that never rotated), that --nav-h computed to 48px
so the phone-sized landscape query really fired, that the activity was NOT
recreated (the same window token and the same performance.timeOrigin, the
same pid, no AndroidRuntime fatal — the manifest declares orientation and
screenSize handled, so a recreation here would be the bug), that the tab
and every stack are where they were, that the track is still playing and past
where it was, that nothing is clipped, and that the glass bar, the mini bar
and the screen spend whatever env() says. Then the player sheet sideways:
two columns, every control on screen, its own side padding. Then back.
The four safe-area insets are printed, not asserted. Nobody had ever measured
them on this phone: MainActivity calls androidx's enableEdgeToEdge(),
themes.xml sets no cutout attribute and nothing in the generated Kotlin
touches insets, so env() comes straight out of Chromium's own window-inset
mapping and whether the hole-punch shows up as a non-zero side inset depends
on the cutout mode that picks. The run records the numbers — insets as
top/right/bottom/left, insetsReal, the app's own --inset-left/right beside
them, and chromePct — and a later round can assert them once they are known.
Asserting a number nobody has measured is how a round fails on a correct
build.
Putting the phone back is the part that is written carefully, because the
phone is Kyle's. The saved pair is a module-level latch, and the restore is
reached from four places: the rotation block's own finally, main's
finally, process.on("exit") and SIGINT/SIGTERM/SIGBREAK. It has to be all
four — a failed clause only pushes a problem and walks on, but still() and
the probe call are wrapped in nothing, so a WebView that stops answering
rejects after 45 s and throws clean out of the loop, and Ctrl-C runs no exit
handler at all. The latch clears itself before it writes, so only the first
caller acts and a restore that fails hands the latch back for the next one.
The rotation goes back before auto-rotate does, so a phone that had the sensor
in charge gets it back last. A device whose two settings will not read is
skipped with a line rather than forced — rotation skipped: the phone's rotation settings would not read — as is one that is already sideways, and
--no-rotate skips the block outright for a run where he is holding the
phone. Nothing else on the device is touched: no download, no room, no
message, no sign-in, nothing written to the config or the library. The one
case no handler covers is a hard kill of the machine, after which auto-rotate
is a Quick Settings tile away.
The stills are phone-turned.png, phone-turned-sheet.png and
phone-turned-back.png.
The phone walk is the same driver on the real thing (plus a step kind of
its own: { adb: [...] } runs an adb command — the share-sheet intent, a
key — and a step may carry a probe as well, a function whose reading of
the system is merged into that step's own expectation, which is how the
shade's notice is asserted out of dumpsys notification; the shade steps
push two rows straight into the transfer store rather than fetching
anything, on purpose; every CDP call times out after 45 s and the reload step re-attaches
to the page, since a WebView that stops answering once hung a walk for ten
minutes; its audio check counts SoulStream's OWN tracks' underruns from the
mixer dump — the thread-wide counters move with any app's notification
sound, and sent the walk chasing dropouts that were not ours): a debug build's WebView
exposes DevTools on localabstract:webview_devtools_remote_<pid>, adb forward puts it on a local port, and the steps run against the real library
— real playback, real covers, the colour following the record — with the
phone's own frames (adb screencap) as the stills. It refuses to run when
another app is on the phone's screen (it never takes the phone from whoever
is holding it), and a WebView in the background does not answer, so the app
has to be in front. One of its steps is location.reload(): the audio
thread keeps playing and the session comes back live, which is the reload
half of session restore tested for real. After the steps it force-stops and
relaunches the app (the restart half: paused at the position, play resumes),
checks the system's media session, sends media keys, backgrounds the app for
ten seconds, and — with the app signed into Soulseek — types --query into
Search, taps the first catalog song and watches it resolve and stream (the
peer, the phase, the coverage), then asks for the raw peers. --lockscreen
takes the lock-screen card's still last, because it leaves the phone locked;
--only-lock takes just that still of whatever is on the bar (resuming it if
paused) without the walk.
What each walk actually proves
The two step lists are not the same test, and which is which had never been written down. The mock walk is the bigger one (252 named steps) and the only one that can arm a failure, turn the phone over or answer as a fresh install. The device walk (121 named steps, up from 88) is the smaller one and the only one that touches a real core, a real library, a real audio thread and the system underneath them. The gap between them is where this list lives.
- Writing it down turned up four phone steps —
library-ambience,queue, lyricsandcommunity— that were named, screenshotted, and asserted- against nothing at all: the driver reads `expect[s.name] ? expects.name
- true
, so a missing key inPHONE_EXPECT` is a silent pass, and all four had one. They have expectations now.
Proven on the device. Boot and its census; playback out of the real
library; the ambience following a real cover — the accent is in the still and
asserted by neither walk; the artwork viewer; the queue reordered, and a queue
row's sheet opened; the lyrics face, the nudge, the per-track auto-sync
switch, the tap room and the real-DSP estimate (all of it discarded); a reload
that keeps the audio thread; a library track's own sheet, off a real path; the
Library's shape, its sort and every one of its five tabs; Search and its
recents; a playlist made, filled, dragged, a row removed and put back by Undo,
filtered and deleted; four import doors — including the folder door on a real
folder, and two of their system pickers dismissed with BACK — and the review's
Keep-unresolved switch; the share-sheet intent; six of You's switches and
chips written to the real config and read back out of it, then put back —
volume normalization, word-by-word fill, the upload slots, the streaming
ceiling, the embedded-cover size and the stream cache's retention — and the
listening port both ways and a refused one; the sleep timer; the Wi-Fi-only
guard; the shade's notice read back out of dumpsys notification; contacts
read over real IPC; a cold artist page, its ⋯, the whole-discography page and
its A–Z; the first-run preview with setupWrites at 0; eleven raw backend
strings through __ss.say; and a refused transfer's sentence.
Then, after the step loop, twelve things the mock cannot do at all: a
force-stop and the paused restore that follows it (waited out past
twenty-five seconds, which is well past the ten-second FGS-kill window), the
position restored, playback resumed, the system media session and its
ownership, media keys, exactly one card in the shade and exactly one session
in the system's list — the two checks that prove the media3 migration landed
rather than doubled — the two intent filters Android Auto knocks on, the
legacy browse one and media3's own, both resolving to MediaService
(auto-browse, auto-library: the catcher for a plugin module that
compiled and never reached the APK, which is how this project once got a
start-up ClassNotFoundException), ten seconds in the background with the
position still advancing, SoulStream's own mixer underruns, and the queue
window that rides along with every card update and that the lock-screen card
is now built out of. Two more lines print and never fail: whether the service
is foreground, whether our audio focus is in the system's stack and whether
the becoming-noisy receiver is registered; and the Auto declaration as
tools/auto-prober/ last read it, because pm dump prints no application
meta-data and nothing else on the phone can be asked. What none of it touches
is in "What the walk proves about the card, and what it does not", above.
One block in the phone walk pulls bytes, and only when the app is signed in:
the trailing network block types --query into Search, streams a peer's copy
into the stream cache, and browses the raw peers and a share. Nothing else in
either list downloads, joins, sends or signs in.
Mock-only, and why (178 named steps). These are the reasons rather than a partition: a step can sit under more than one of them, and the last bullet is the only one that says a step could move and has not.
- Needs
__ssMock— the nine weather steps and the fifteen failure faces. That object is the mock library's own door and does not exist in a real build. The SENTENCES those screens print are proven on the device anyway, bytrouble-wordsandtrouble-xfer; the screens that carry them are not. - Needs a fresh install — the ten
fresh-*steps. On the device only the preview door is walked, and it writes nothing. - Needs a viewport override — the nine landscape and portrait steps. A
viewstep on the phone walk is a deliberate failure, because a real device's orientation is not the harness's to set, so the sideways layout has never been seen on a device at all. - Needs the dev flavour —
diaganddiag-back.devBuildisimport.meta.env.DEVor the mock key, and the embedded build Kyle carries reads false, so the row is not there to tap. - Would download, save or rewrite his files — the catalog, peer, browse and Save steps, the cover re-fetch, the portrait refresh, chosen and pasted lyrics, a saved sync, the vocal-reduced copy, a hero photo set.
- Would join, send or sign in — rooms and their users, buddies, the contact editor, the taste chips, a user looked up.
- Would change what is playing —
queue-removedandqueue-moved-up. The device opens that sheet and reads it; it does not tap a row in it. - Destroys his data —
history-confirmandhistory-cleared. Never on his phone. - Structurally empty on this phone — Home's feed sections and the Gaps
shelf. Follows live in the library folder and the phone's own folder starts
without any, so on the device
gapscan only be asked to render and to stay quiet. - No reason but time — not every remaining step has a reason above it.
These would be safe on the device and simply have not been moved: the
sheet's own Move-up and Move-down rows and a drag refused under a filter
(all three on the walk's own playlist), rename, unpin,
wifi-off, the empty search and the did-you-mean pair (both wanting a query his own library can answer for), and the several-againsteps that only put a screen back. This round stopped where it stopped.
Conditional on the device. Six of the new steps pass vacuously when his
own data cannot produce the condition, and are named here so they do not
become the next generation of silent passes: gaps (no follows),
queue-row-sheet (nothing after the walked track), playlist-drag-held and
the drag-refusal clause of playlist-filter (both need a playlist longer than
one row — a one-track record makes a one-row playlist, and reorder.js
refuses to lift a list that short), import-keep-off (a paste that resolved
completely), and you-keep (the cache already set to Forever, which the step
will not move because coming back writes the flag and not the number). The row
count playlist-row-sheet stashes is what keeps the rest of that block from
passing on a probe that read nothing.
Needs a person. A live room joined and a message sent; a real Save over
cellular with the Wi-Fi switch on; the lock-screen card (--lockscreen takes
that still and asserts nothing about it); the first real export written
through Android's create-document sheet, which the walk only ever dismisses
with BACK; the phone physically turned sideways; and a genuine first run on a
device that has never had the app.
The Auto prober
bash tools/auto-prober/probe.sh # depth 2, the ordinary root
bash tools/auto-prober/probe.sh 3 true # depth 3, the RECENT root
adb uninstall com.soulstream.autoprober # and it is gone
Android Auto reaches a media app through MediaBrowserService, with the
app's process cold — no Activity, no WebView, and therefore no DevTools
socket. smoke.mjs drives the phone over CDP, so with SoulStream
force-stopped it has nothing to attach to and is blind to every bit of this.
tools/auto-prober/ is a throwaway app that binds the same way Auto does,
with Auto's own root hints, walks whatever tree comes back, and prints one
JSON object per event under the logcat tag SoulAutoProbe. It is
framework-only (android.media.browse.MediaBrowser, not androidx, not
media3) because that is the path Auto itself takes into a media3 app, and a
prober built out of the library it is probing would not prove that path
works.
It lives outside src-tauri/gen/android/ deliberately — npx tauri android init would delete it — and borrows the generated project's Gradle wrapper
rather than shipping one, which pins Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25
and JDK 17: what is already in the cache, so a run downloads nothing.
probe.sh builds, installs, clears logcat, force-stops SoulStream (only;
the walk already does the same), starts the prober by name, waits, and prints
the log. No permissions beyond <queries>, no network, no storage, no
transport command; every exit path disconnects and onDestroy disconnects
again.
The events are start, desc, filter, root, children, fail, done.
The number it exists for is root: milliseconds from connect() to
onConnected() with the process cold, next to the root media id and
whether a session token came with it. Auto gives a media app a few seconds
and then drops it, which is why onGetLibraryRoot answers with
Futures.immediateFuture and nothing else — MediaLibraryServiceLegacyStub
blocks the binder thread on that future with no timeout.
desc is the half the walk cannot do. adb shell pm dump is dumpsys package: resolver tables, permissions, package state, and no application
meta-data at all — so auto-browse and auto-library can prove the intent
filters and nothing else. The prober resolves
com.google.android.gms.car.application through
PackageManager.GET_META_DATA and then opens that resource out of the app's
own Resources and reads the <uses name="media"/> inside it, which is the
only way to tell a real declaration from a dangling id short of unpacking the
APK. The walk echoes the last such line back as the advisory auto-desc.
androidx/media #3158. Open since 2026-04-03, still open, reproduced on
media3 1.11.0: a host that binds a MediaLibraryService while nothing is
playing can put the service into an onCreate → onBind → onGetSession → onConnect → onDestroy loop at about thirty cycles a second. It is 100%
reproducible after a force-stop with no existing binding, which is exactly
what this prober does — so it shows up here before it ever shows up in a car.
MediaHub holds the session, the player, the notification provider, the
audio-focus request and the becoming-noisy receiver for the life of the
process, so a cycle allocates and registers nothing and the loop is
survivable; the loop itself is Android Auto's to fix and media3 has said it
will not act first. The counter is the instrument:
adb logcat -d -s SoulStream:I | grep "auto service created" | tail -1
One or two is normal. A number climbing while a probe or a car is connected is the bug, and it is worth watching across a LONG bind rather than a short one — the failure mode this guards against is an OOM hours in, not a crash at connect.
None of this proves the car screen. The Desktop Head Unit or a real head unit is the only thing that shows what Auto draws, how it groups a tree, or whether a tap plays. This proves the app answers.
Not done yet
Thirty rounds in, this is the honest map: what is deliberately absent, what is waiting on Rust, and what is simply smaller than the rest. Most of what the backend exposes and the phone never calls is a whole module it has no business calling, so the list is by module — that is the reason, not the symptom.
-
Off by design — the private layer. Spaces, holdings, the sealed inbox, friends by key, shared playlists, Who has it, the persona and its profile, and the Soulseek↔persona link: sixty-six commands across ten modules (
freenet7,friends7,holdings8,inbox3,persona6,profile4,proposals3,shared_playlists13,slsk_link5,spaces10). Freenet on the phone is a later experiment; the network side is in, and rooms and chat are built and mock-walked. A live room is Kyle's to join. -
Off by design — the desktop's own machinery. The tag-import pipeline (all sixteen of
import.rs: its audition, its cover report, its portable root — which is the import session's root, not the library folder); hover previews (all eight ofpreview.rs), and with them the three pause/fade acknowledgements that live there because they exist only to hand silence between the main player and the preview player; the mini window, the glass mode, Show in folder and SMTC; the updater (the phone's own build of it answers honestly that there is no channel here, and the version is in You, fromapp_status); the hero GPU upscaler, the cover-wordmark OCR and its debug tools. The phone shows a wordmark the page carries; it does not run the pipeline that makes one — and it does not fetch a 3000px cover upgrade over mobile data for a screen that cannot show it, which is whycatalog_coverandinvalidate_catalog_coverstay here too. -
Off by design — external runtimes. yt-dlp and deno, the vocal separator, the lyric alignment models, and the listener door that needs them.
-
Still desktop-only, smaller. Catalog track previews and the fused-artist portrait verdict — that last one needs the identity merge the phone does not run, so porting it means porting the merge first.
-
Per library folder. Follows and the play history live in the library folder, so the phone's own folder starts empty of both; they arrive with a shared library.
-
A first run that restores rather than starts. The tour covers the two things the app cannot work without (a folder, an account) and stops there. The desktop's own tour gates on an alpha key, which has nothing to unlock here — the APK is side-loaded and there is no updater channel on the phone — and its Community step has no counterpart, because Freenet is not on the phone. The honest first run for Kyle's own phone is neither: it is "point me at the desktop's library folder and read its follows, its playlists and its play history", which works only when that folder is reachable from the phone's storage. That is a sync door, not a setup step. Making an account from inside the app is possible — Soulseek makes one from the first sign-in with a new name — but it would mean handling a password, which the tour deliberately refuses.
-
A release build on the phone. R8 has now run and the keep rules hold — but on an x86_64 APK, on an emulator, not on
assembleArm64Releaseand not on Kyle's phone. R8 wrote nomissing_rules.txtat all (so the-dontwarnpair covers what jackson-databind reaches for),lintVitalX86_64Releasepassed, zipalign and apksigner with~/.android/debug.keystoreproduced an APK thatadb install -rput over the debug app without touching its data, and the minified app launched, rendered Home, restored its session and walked 131 of 131 with no exceptions, no console errors and nothing in logcat about a missing command or class. What that does not cover: the arm64 variant and thecargo build --releaselibrary the script actually names — only the flavour differs, and the Gradle, R8 and signing steps are the same ones — and the hands-only list below, the pickers and the notification buttons and 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, 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 thequeueBase→playQueueAt()mapping for tapping a queue row are still absent.