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