Files
SoulStream/ANDROID.md
T
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00

1022 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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), 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-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` works;
- 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 **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.
**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
without a drag (`playlist_set_order`, the whole order in one call, as the
desktop's drop does).
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.
- **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 four doors — 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 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`).
- **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 playlist-file door and export — both want a file
picker the storage plugin has still to grow — the listener door (it needs
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
## 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.
## 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 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
```bash
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
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:
```bash
# 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):
```bash
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):
```bash
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
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
```bash
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 → 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, 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 share
from another app) → the playlist repair (an unknown row relinked from the
library) → the artwork viewer → a queue row's sheet → 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, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The block at 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. 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. Nine mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). 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.
## 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.
- **Waiting on Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog`
does open Android's create-document sheet from Rust, but it answers with a
`content://` URI and `playlist_export` writes with `std::fs::write`, which
cannot take one. It needs a ContentResolver write, or the bytes handed to
an outbound share intent — and `crates/tauri-plugin-share` is inbound-only
today. Playlist-FILE import wants a `pick_file` in the storage plugin,
beside the `pick_folder` and `pick_image` it already has.
- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews, the full discography page, 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.