# 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 `` 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 `/stream-sources.json` (`stream_sources.rs`) — one entry every time a source is remembered, and the whole store once at boot for memories made before the mirror existed — and `auto_browse.rs` looks a streamed row up there and computes its cache file with the same hash `playback::cache_path` uses. A row whose file is whole plays from it and carries the host's "downloaded" mark; a row whose bytes are not here is still a row that plays, and the difference is only that mark. `resolve_playable` queues the WHOLE playlist in order (`headless::PlayTrack.path` is `None` for a row not here, `stream` its remembered peer file), and the engine's queue (`headless.rs`) works like the page's: a row is FOUND when the queue reaches it — the remembered peer file first, else one search through the cold Soulseek session (`headless_slsk.rs`: the stored account, the server's excluded phrases stripped, copies ranked close to `songCandidates`, the best three tried with a fifteen-second first-byte watchdog) — the bytes land in the stream cache and play as they arrive, the next THREE rows are fetched ahead (`PREFETCH_DEPTH`, after the page's four-second start delay), and when the immediate next is whole before the current one ends it is appended to the sink for a gapless boundary (`Queue.armed`, checked again at the boundary). A row nothing on the network has moves the queue on, the page's rule; only the LAST row's failure reaches the card. The card shows the title at once with a BUFFERING spinner (`headlessState`) and the track once bytes play. One Soulseek session per process: when the app opens over a car session, `login_inner` ADOPTS the client (`headless_slsk::adopt` + `subscribe`) rather than signing in again — two logins from one address in quick succession is what the server throttles — and the fetches in flight become the page's own preloads (`PlayerState.preloads`), so the row the car was fetching ahead lands gaplessly under the page too. A playlist's subtitle counts cached streams as "here". A tap while the app is UP is still handed to the page (`AutoNative.describe` → `playMediaId`), which owns the queue then. **Covers, always.** The car draws the app icon over a row with no cover, and thirty of GhettoTechie's forty rows had none — pasted-tracklist rows the catalog never matched. Two hands fill them. In the cold process, `auto_browse::embedded_cover` extracts the picture from the file's own tag (the desktop's `tagger::extract_art`), keyed by the AUDIO path under `/.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 `/resume.json` (`resume.rs`) by two hands: the page names the queue (a window of 25 either side of the current track, each entry by the file that plays it cold — a local path, or the peer file whose cache may hold it) every time it writes its own session, and the engine's pump keeps the position, every five seconds while playing and at once on a pause or seek. Three things read it. media3's `MediaButtonReceiver` is now declared, so a PLAY key into a dead app — a Bluetooth head unit's button, the Quick Settings resume card, Android Auto's autoplay on connection — starts the service and lands in `LibraryTree.onPlaybackResumption`, which answers with the `ss:resume` row and the position; media3 sets it, and `play_media_id("ss:resume", startMs)` starts the saved queue at the saved entry and seeks. A host asking for the RECENT root (`EXTRA_RECENT`) gets a root whose one child is that row, "Continue · · m:ss". And the Recent tab lists it first. An entry whose file is gone is skipped; if the one that was playing is gone, the next playable one starts at zero rather than three minutes into a different song. **The handover.** When the app opens over a car session the engine is already playing a queue the page has never heard of. `playback::init` takes that queue off the headless engine before clearing it, `player_state` hands it over once, and `restoreSession` builds the bar from it — instead of restoring the page's own older session over live audio, which put the wrong title on the bar. **What the car is told.** A tap that cannot play — a streamed row no peer on the network has, a hand-off the resolver refused, a row whose file is gone — used to be silence. `MediaHub.fail` now puts the reason in the playback state (`STATE_ERROR` with the message, which Auto draws as a card) and sends a `SessionError` carrying "Open SoulStream" as the resolution action; the next track that plays withdraws it. The page reports its own verdict back through `remote_failed` when a hand-off fails. **The notification storm, found and fixed.** 2026-09-18's logcat had the card re-posted six times a second for over a minute, then "Too many PendingIntent created" (media3 mints a fresh one for the custom button on every rebuild) and twenty refused foreground starts in two hundred milliseconds. The cause was a loop between `MediaService.onStartCommand` and media3: media3 starts the service ITSELF on every foreground notification update (`MediaNotificationManager.startForeground` → `startForegroundService(startSelfIntent)`, a bare component intent with a uid extra and nothing else), and `onStartCommand` promoted on every intent — re-posting the card, which made media3 start the service again. It ran for as long as the service was in the foreground: while a track played, and for the ten-minute tail after it paused (the capture shows it stopping at exactly pause + 10 min). `onStartCommand` now promotes only for an intent that carries an action or a data uri — a media key, a card button — and leaves media3's self-start to media3. Separately, every `refresh()` of the media3 player goes through `MediaHub.refreshSoon`, which coalesces asks inside a 250 ms window and logs `card asked to rebuild Nx in 10s` when the rate is silly; it stayed silent through the storm, which is what pointed away from our own rebuilds and at the service restart. ## What a screen reader says Every control on the phone is named by what it does, in the app's own words, and where the desktop already has a word for something it is the word used here too — Seek, View album artwork, Close artwork viewer. An icon-only control carries an `aria-label`; one with visible text is named by the text it shows. The nav is a tab set: `role="tablist"` on the bar, `role="tab"` and `aria-selected` on the five destinations, and the count badge round seventeen added is part of the tab's own name — "You, 3 transfers", "Community, 2 unread" — rather than a digit read out before the word it belongs to. The badge itself is `aria-hidden`, because the number is in the name now. The two sheets are dialogs: `role="dialog" aria-modal="true"`, the player sheet named for what is playing, the half sheet reading its own first title back out of the DOM so that twenty-seven kinds of sheet need no list of names to keep in step. Opening one puts the focus somewhere sensible — the player sheet on its Close, the half sheet on its grab handle, which is a real button now ("Close this sheet") and the first dismissal a screen reader could ever reach; the scrim behind it never was one. A sheet that is down is `inert` and `aria-hidden`. Neither sheet is unmounted — they are translated off the bottom of the screen, and the half sheet deliberately keeps its last body rendered through the slide down — so before this the closed sheets' rows, the hidden mini bar and the nav under an open sheet were all still in the accessibility tree, and TalkBack could swipe into every one of them. There is one live region, `.sr-live`, mounted for the life of the app and clipped to a single pixel, because a region that appears with its words already inside it is announced reliably by nobody; an announcement here is a change of text in a node that was always there. That is what carries an error and a notice, so an Undo offer is actually read out — and the Undo button itself stays reachable, only the sentence beside it is hidden from the tree so it is not said twice. Artwork that opens the viewer is a control, not decoration. Artwork that is decoration carries `alt=""` and every icon is `aria-hidden`, so the tree never reads a filename out. The waveform is `role="slider"` with a value and arrow keys where it can seek, and `aria-hidden` on the mini bar where it is only a picture of the position. Switches say on or off, choice chips say pressed, and the two long descriptions and the gap line say expanded. The check, so this does not quietly rot: the walk's probe sweeps every interactive element in the live DOM and counts four things — the ones with no accessible name (`a11yAnon`, with `a11yWho` naming the first three offenders so one run tells you what to add), the ones whose name is junk (`a11yBad`: bare digits, a filename, a lone glyph, an essay), the ones sitting in a layer that is off-screen but still in the tree (`a11yGhost`) and the ones that look chosen without saying so (`a11yState`). All four are asserted at zero on Home, the library, a record, a catalog record, the artist page, You, the taste page, a half sheet, the player sheet and the viewer — and the anonymous count on a room and on both shapes of prompt besides, because a room's message box and the prompt's own field are where the fields this round finally named actually live: assert it only where they are not and the round guards everything except its own work. One known exception, left alone on purpose: the per-row ⋯ is a `role="button"` inside the row's own `