A Save still running with the app in the background now posts a progress notice — how many files, the one it is on, a bar, Stop all and Open — and takes it down when the queue empties. It sits on its own low-importance channel so it can be silenced without silencing the player's card, and rides its own dataSync foreground service rather than the media one: a mediaPlayback service stops itself when the bar empties, a download is not playback, and nothing else was keeping the process alive for a Save with nothing playing — which is how the first real Save died, four tracks in. Stop all goes through the same cancel(id) a row's tap takes, and Transfers grew the matching button beside Clear finished. The walk pushes two rows straight into the transfer store and reads the real notification back out of `dumpsys notification`, without fetching anything.
859 lines
56 KiB
Markdown
859 lines
56 KiB
Markdown
# 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 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.
|
||
|
||
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. "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.
|
||
|
||
## 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 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). 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
|
||
|
||
- Community's private layer (Spaces, holdings, the sealed inbox, friends by
|
||
key, Who has it) — Freenet on the phone is a later experiment; the
|
||
network side is in. Rooms and chat are built and mock-walked; a live room
|
||
is Kyle's to join.
|
||
- The desktop-only doors and tools: the playlist-file import and export (a
|
||
file picker the phone's storage plugin has still to grow), the listener
|
||
door and the lyric alignment models (a desktop runtime), drag reorder
|
||
(Move up / down / to the top stand in), catalog track previews, the full
|
||
discography page, the artist-artwork upscaler, updates and the account
|
||
section (sign-in lives on Community).
|
||
- Follows and the play history are per library folder, so the phone's own
|
||
folder starts empty of both; they arrive with a shared library.
|
||
- 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.
|