Files
SoulStream/ANDROID.md
T
KeelrLeek 0ce8c63cb6 android: what the device has actually been tested for — the map, and the steps it could close
Most of this port is proven against an object in memory and only some of it
against the phone, and nobody had written down which was which. Writing it
down found four phone steps — `library-ambience`, `queue`, `lyrics` and
`community` — named and screenshotted with no entry in `PHONE_EXPECT` at all,
and the driver reads a missing key as a pass, so all four had been green since
the rounds that wrote them; they have assertions now, tolerant of Kyle's own
library and of nothing else.

Thirty-three more steps move onto the device: everything that can be proven
there without downloading, joining, sending, signing in or writing to his
library. The library's shape and its sort (nav state, not config), a local
track's sheet off a real path, Search's recents out of this WebView's own
storage, a queue row's sheet opened and not tapped, the walk's own playlist
with a row removed and put back by Undo — the first round trip that stash has
made through real SQLite — the import review's Keep switch restored before the
draft is created, the sleep timer set and put out by the next step, the
stream-cache chip and the Wi-Fi-only guard read first and restored last, and a
port the core has to refuse.

ANDROID.md carries the map beside Verifying: what the device walk proves (121
named steps, up from 88, plus the seven things after the loop that no mock can
do), what stays mock-only and why — needs `__ssMock`, needs a fresh install,
needs a viewport the phone walk refuses to set, needs the dev flavour, or
would download, join, or destroy his data — which six of the new steps are
conditional on what his own library happens to hold, and what still needs a
person. No product code in this round: four inserts in the phone half of
smoke.mjs, one in the doc.
2026-09-14 05:20:02 -05:00

98 KiB
Raw Blame History

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 foreground service (MediaService) that owns the MediaSession, the MediaStyle notification, audio focus and the becoming-noisy pause. 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/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)

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's getDataDir(), so /data/user/0/com.soulstream.app/Library) until the SAF folder story lands;
  • the Soulseek password lives in secrets.json under the app's private data directory (config::save_password and 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_folder answers "nothing picked" (desktop-only dialog); save_file opens Android's create-document sheet, but answers a content:// URI that std::fs cannot open — which is why the playlist export goes through the storage plugin's create_document instead;
  • 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. 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 — coverFor in playlists.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 through hooks.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 by plItemId on 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 through playlist_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 ⋯ and oncontextmenu is 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 carries touch-action: none: a flick that starts on a row still scrolls the list. touch-action is latched at touchstart and cannot be flipped mid-gesture, so the page's own scroll is refused instead by a non-passive touchmove listener 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's rowH), because the queue face puts an album separator between rows. The drop reuses the mutations the Move rows already use: moveItem and moveInQueue each take a numeric target now as well as "up" / "down" / "top", so there is still exactly one call to playlist_set_order with 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.dragging also defers the page's own re-read while a drag is live, the phone's version of the desktop's skippedWhileDrag), and the queue drops nothing if player.qi has 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 (runBulk in playlists.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, and playlist_cover_set installs 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 to playlist_import_folder, which grew a path argument 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's pick_file, and playlist_import_probe grew path AND name for it — the picked document's own display name is what detect_format reads 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 by playlist_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-dialog does open Android's create-document sheet, but it answers with a content:// URI and std::fs::write cannot take one, so on the phone the bytes go to tauri-plugin-storage's create_document and 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's Ok(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's ShareActivity (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 asks pending_share at 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

The service is started with startForegroundService, so it promotes itself FIRST in onStartCommand — with the card there is, or a plain one — and only then mirrors the state, stepping back (STOP_FOREGROUND_DETACH) when the track is paused or stopping when the bar is empty. Android 14 kills the whole app about ten seconds after a foreground start that never called startForeground(), and every restart restores a PAUSED track, which is exactly that case: on 2026-09-13 the first real Save died with the process that way, four tracks in. The check: force-stop, relaunch with a paused track, wait 25 s, the pid survives.

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 two files — the plugin (arguments in, the action event out) and MediaService, a mediaPlayback foreground service holding a MediaSessionCompat and a MediaStyle notification. Playing → startForeground; paused → the service steps back and the card stays, dismissable. Android 13+ asks for POST_NOTIFICATIONS the first time something plays. Audio focus is requested while playing (loss pauses, a transient loss pauses and resumes), and headphones coming out pause. The app's capability grants media-session:default; the plugin is registered on every platform so the permission resolves everywhere, and on the desktop its commands answer "not here" (the desktop has smtc.rs).

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.

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

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_index drew "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_items said "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 catch was 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.js sets slot.error and 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 is failed · <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 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.

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 with sdkmanager alone — 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 no cargo 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
# 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.

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 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,
lyrics and community — that were named, screenshotted, and asserted
against nothing at all: the driver reads `expect[s.name] ? expects.name
true, so a missing key in PHONE_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, seven things the mock cannot do at all: a force-stop and the paused restore that follows it (waited out past the thirteen-second FGS-kill window), the position restored, playback resumed, the system media session and its ownership, media keys, ten seconds in the background with the position still advancing, and SoulStream's own mixer underruns.

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, by trouble-words and trouble-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 view step 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 — diag and diag-back. devBuild is import.meta.env.DEV or 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-removed and queue-moved-up. The device opens that sheet and reads it; it does not tap a row in it.
  • Destroys his data — history-confirm and history-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 gaps can 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 -again steps 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.

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 (freenet 7, friends 7, holdings 8, inbox 3, persona 6, profile 4, proposals 3, shared_playlists 13, slsk_link 5, spaces 10). 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 of preview.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, from app_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 why catalog_cover and invalidate_catalog_cover stay 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.