Files
SoulStream/ANDROID.md
T
KeelrLeek aff29aba5c android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
The now-playing sheet's streamed track can be copied into a folder picked
on the phone, as the file it is — the cache written out, nothing downloaded
again, no library row (copy_stream_to_folder, which the desktop has had a
command for and no UI). You → Sharing carries the listening port, what
peers dial when they download from you: unset is the client's own 50300,
the app asks the router to forward it over UPnP and falls back to the
server relaying, and a change applies on the next connect. The dev flavour
gets the desktop's debug overlay as a screen under This phone — CPU,
memory, the network, the stream and its loudness, what the pre-buffer is
fetching, the size of the play history — behind devBuild in util.js, which
is Vite's own DEV flag, so the embedded build the phone keeps has no row
to tap.
2026-09-13 23:59:47 -05:00

45 KiB
Raw Blame History

SoulStream on Android

The phone is the same Tauri 2 project as the desktop: the same Rust core in src-tauri, the same commands over IPC, a third Vite entry for the phone's own UI. Started 2026-09-12 on a Pixel 6a (Android 16). The design it implements is research/mobile-mockup/ (gitignored; see its README).

What is where

mobile.html → src/mobile-main.js → src/mobile/Mobile.svelte the phone UI (its own Vite input, next to index.html and mini.html)
src/mobile/mobile.css the foundations: the desktop palette, phone radii/spacing, Android insets, the sampled springs, the registered --accent and its transition
src/mobile/shell.css the shared vocabulary, global on purpose: ground, glass bar, cards, rows, chips, the record head, the mini bar, the nav, the player sheet, the lane, half sheets, the screen transitions
src/mobile/nav.svelte.js a stack per tab; the browser history mirrors it one entry per back-able layer so the system back gesture peels the sheet, a half sheet, a pushed screen, then the tab
src/mobile/player.svelte.js the queue (a predetermined shuffle order, the unshuffled queue kept), the transport, gapless preload the desktop's way (preload_local, track-ended { gapless }), the session in localStorage (a reload follows the live audio thread, a restart restores paused with needsResource), the sleep timer, the lane's story (hold → arriving), the play-history ticks (the desktop's rule, the same table)
src/mobile/art.svelte.js covers behind a LIFO gate (3 in flight), the visible action, the accent per cover through src/lib/color.js; an https URL goes through the desktop's art proxy (catalog_art)
src/mobile/lib.svelte.js albums, recents, follows, playlists; artists derived from the albums
src/mobile/catalog.svelte.js the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id
src/mobile/media.svelte.js the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions
src/mobile/slsk.svelte.js the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's slsk store and events
src/mobile/contacts.svelte.js the local view of a person on the network: a petname, a group, a note and the two marks (contacts_meta_list / contact_meta_set) — this phone only, published nowhere
crates/tauri-plugin-media-session/ a Tauri plugin with a Kotlin foreground service (MediaService) that owns the MediaSession, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows
src/mobile/Sheet.svelte, HalfSheet.svelte, SyncRoom.svelte, Lane.svelte, Cover.svelte, Release.svelte the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet)
src/mobile/screens/ Home, Search (the library half: records and artists here, tracks through library_track_search), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real)
src/mobile/prefs.svelte.js Appearance, kept in localStorage: ambience, accent from artwork, motion
src-tauri/tauri.android.conf.json merged over tauri.conf.json on Android: one window at mobile.html, no desktop resources, minSdkVersion 26
src-tauri/gen/android/ the generated Gradle project (npx tauri android init); minSdk = 26. Gitignored
src-tauri/android/build.sh, deploy.sh the detour build (below) and build → install → launch → screenshot
src-tauri/src/android_glue.rs JNI_OnLoad: hands the JavaVM and app Context to ndk-context (cpal's AAudio backend needs it; tao 0.35 never fills it)
src-tauri/src/updater_mobile.rs the updater's stand-in on mobile (#[cfg(mobile)] #[path])
src-tauri/src/lyric_align_stub.rs, vocal_sep_stub.rs stand-ins off Windows for the two Windows ML modules
tests/mobile/smoke.mjs, perf.mjs, cdp.mjs the phone UI walked in headless Chrome or on the phone, the frame-time probe, and the DevTools client they share (below)

Android-only manifest tables: src-tauri/Cargo.toml and crates/metadata/Cargo.toml give reqwest rustls-tls on Android and the platform TLS everywhere else (no OpenSSL to build against on Android). The tables are exclusive on purpose — an unconditional reqwest entry unifies default-tls back in. tauri-plugin-updater is a desktop-only dependency.

What the phone does differently at runtime:

  • the library root is app_data_dir()/Library (Android's getDataDir(), so /data/user/0/com.soulstream.app/Library) until the SAF folder story lands;
  • the Soulseek password lives in secrets.json under the app's private data directory (config::save_password and friends on Android) — there is no credential manager the keyring crate speaks to, and the app directory is what Android's account storage amounts to for an app;
  • the audio output stream asks for an 8192-frame buffer (open_output_stream); AAudio's default put it on the fast path with a 10 ms burst, where the mixer's inline decode and file reads underran audibly;
  • pick_folder answers "nothing picked" (desktop-only dialog); save_file works;
  • the window-glass command is a no-op (the ground is the artwork);
  • the aligner and the separator report "not on this platform";
  • the desktop's Win32 perf counters read 0 / None in the debug overlay.

The shell, on real data

The mockup's rules, kept: the page ground is the artwork in play, blown up and blurred (two layers, so a change of record crossfades); the accent follows the artwork per screen — a record page wears the record's colour, an artist page the artist's, everything else what is playing — extracted with the desktop's own color.js and crossfaded by the compositor through the registered --accent. The glass bar is GlassBar.svelte's recipe and stays anonymous until the page's large title has scrolled under it. The mini bar is the desktop's 62px row over a 16px lane strip; tapping it is a container transform (the small art flies to the big one). The player's scrubber is the lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into linear(). Reduce motion (You → Appearance, or the OS setting) turns it off.

Search and the resolver

Search is the desktop's top bar: the field asks the catalog once the typing pauses (catalog_search — Deezer, MusicBrainz and Apple behind it), and shows artists, a rail of records and songs, then what the library holds, then — offered, not run — a raw Soulseek search grouped by peer (a peer's copy plays from its row, the lane rising as the bytes arrive). A song row, a catalog record's tracks and its play disc, and Popular on an artist page all play through the resolver.

The resolver is the desktop's, not a fork of it: the source rules — the query ladder with its poisoned-term hygiene, the paced search with its early exit, the quality window, the candidate scoring for songs and folders, the remembered stream sources — live in src/lib/sources.js, one copy for both apps (moved out of state.svelte.js verbatim on 2026-09-12; state.svelte.js imports them back). src/mobile/resolve.svelte.js runs them on the phone: the library first, then a remembered source whose bytes are still complete in the cache, then the network. A record queues every track from the one folder that wins; a song played off a record queues the rest of the record behind it as path-less entries that find their own copy when their turn comes (hooks.resolveEntry). Failover is the player's: an entry carries its runner-up peers in t.alts, and a stream that has not sent a byte in 12 s (8 s for a retry) moves to the next one, then on.

A streamed record's cover comes out of the file's own tags once enough of it is here (stream_embedded_art), and the accent follows it when it lands.

Search leads with the desktop's top result — the most popular exact match of its kind (artist, record or song), scored as a share of its own kind's leader, ties falling artist → record → song — on the featured surface, then artist cards, records, songs. The artist page is the desktop's (CatalogArtist + ArtistExtras) at phone scale: the banner with the wordmark or the name, Play popular, Follow, the stat chips (releases, fans, years active, genre, socials), Popular with listener bars, the latest release, the discography filed the desktop's way (src/mobile/disco.js over the shared localReleaseType.js + editions.js: tabs per category, editions folded into families, the library's copies marked), releases by year, fans also like, what is on this phone, About. Opened from a search row it carries the row's identity (seed), so a namesake can never take the page.

The catalog record page is the desktop's album page: the kind, the artist byline with a portrait, year · tracks · runtime, Last.fm's totals, Play / Save / Shuffle, the record's other editions (the artist's page folded by editions.js, the desktop's own rule), the tracklist with listener bars and disc heads, About this record (Wikipedia through catalog_album_info, the lede promoted), and the release pills with who said so. Save fetches the best folder by the DOWNLOAD rules into the phone's library folder; the pipeline files it and says soulseek:library-changed, which the library store watches, so the record appears on its own. A partial copy (a Save cut short, a rip missing tracks) keeps the button as "Complete · N missing". A long press on a row is the track sheet: Play, Save to library, Go to record / artist. Verified 2026-09-13: Discovery, 14 FLACs from one peer, whole in the library folder 31 s after the tap, filed as "Artist/Year - Album/… - NN - Title.flac" with cover.jpg beside them.

The THREE next tracks are readied while the current one plays — the desktop's depth (STREAM_PRELOAD_DEPTH), in the player's armPreload: a library file is decoded and appended; a peer's copy is fetched into the cache ahead of time; a record's later track that has no copy yet is found first, QUIETLY (a background search never shows the resolver's line); the immediate next is appended the moment it is byte-complete, so the boundary is gapless. Streams wait four seconds after a start so nothing competes with the track that is starting. Verified 2026-09-13 on a record the phone had never played: the first track resolved and started in 14 s, the next three were found and the first of them cached by 16 s, and the boundary crossed gapless (player.advance says how each boundary was crossed, "gapless" or "sourced" — the harness reads it).

The library folder (You → Library → Library folder) is picked through Android's document tree picker in crates/tauri-plugin-storage (the same plugin shape as the media session: links, build.rs with android_path("android"), the capability storage:default). The picked tree's content URI is kept with a persistable grant and resolved to a real path when it is on a volume that has one (primary:Music → /storage/emulated/0/Music; an SD card's XXXX-XXXX: too; a cloud provider's tree has none and is refused), because the Rust scanner and the download pipeline work on paths. That path becomes download_dir — the folder IS the library — and a rescan follows. A folder in shared storage needs Android's all-files access under scoped storage: the plugin declares MANAGE_EXTERNAL_STORAGE, reports whether it is granted, and opens the system screen that grants it (You shows the row only while the folder is outside the app's private data and access is missing). Nothing is read outside the folder the user picked.

Playback (You → Playback) has the desktop's volume normalization switch (−14 LUFS through the player crate's BS.1770 meter; on by default). There is no graphic equalizer in the app — the "eq" in the code is the animated bars glyph.

Home is the desktop's landing page on top of the library rows the phone had: one slot per followed artist (src/mobile/home.svelte.js — their catalog page, the MusicBrainz-only records, six related artists, warmed by home_warm), the feed's lead on the featured surface (a new or coming record; the newest record here when there is none), New from your artists, the news, Jump back in, From the back catalogue, Recommended for you (each card names the follow that earned it), Recently added, Out now, Complete the set, Following. The rules are the desktop's, copied; a follow made anywhere re-walks the slots (soulseek:follows-changed).

Gaps (Library → Gaps) is the desktop's gap line per shelf: only an artist you FOLLOW is asked (an anchored identity, never a name guessed onto a namesake), the catalog's released records minus the shelf's by loose title, dimmed cards that open the catalog record page, the totals remembered in library.gaps.v1 so the line says its number before the lookup.

The server's room list on Community is filtered by name and shows 80 at a time with a way to the rest (Soulseek lists hundreds).

Community's fourth tab is Taste: the interests this phone has declared to the Soulseek server (slsk_interests_mine, slsk_interest_set) and the people the server ranks as close to them (slsk_similar_users), a row each into the peer sheet. A like or a hate is a chip and tapping one takes it back; "Add a like" and "Add a hate" declare a new one. Every declaration asks the server again and the chips stay quiet until it has answered, because the ranking only moves when its input does. The lists are this client's own — the backend keeps them and re-declares every one at login, since the server's copy belongs to the account and another client signed in elsewhere can change it under us. Mock-walked only: a live sign-in and the server's own ranking are Kyle's to see.

A peer, and their shares

A raw result's ⋯ (or a long press on its head) is the peer's sheet: play the folder as a record, save it to the library as it is, browse their files, message them, add or remove them as a buddy. Browse is the hub's Browse tab as a screen (screens/Browse.svelte, slsk_browse_user) — the same sheet is a buddy's on Community → Buddies (a long press), and "Look up a user" there opens it for any name typed (the hub's browse by name): every folder they share with its files, sizes and formats, the totals in the header; a folder opens to its files, plays through the stream engine or saves whole. One request for the whole share, so a big one takes a moment — and a share can hold thousands of folders (the first peer the walk met: 7,511 folders, 158,937 files, 2.4 TB), so the screen shows a filter and renders 80 at a time with a "more" button; rendering all of them at once cost an audio dropout. The phone walk browses the first peer of its network search.

Every name in Community can wear one you gave it. The peer's sheet ends in Name and notes (src/mobile/contacts.svelte.js, contacts_meta_list / contact_meta_set): a petname, a group, a private note and the two marks, Favorite and Trusted. All of it sits in the app's own config on this phone and is published nowhere — Soulseek never learns it, and neither does the person it describes. The petname stands in for the username on the buddy list, in the chat list, on a thread's bar, in the peer sheet, in a room's roster and on a room line, and the real username stays with it — on the line beneath, or beside the name in a room — so a stranger is never quietly turned into someone you know. Favourites sit at the top of the buddy list, a star and a shield mark the two, and clearing a person's details drops the entry rather than storing an empty one — the backend's own rule.

More long presses: a catalog song in the search results opens the catalog track's sheet (play, save, add to a playlist, the record, the artist); a row of the queue face opens the track's sheet with Play now, Move to next, Move up, Move down and Remove from queue on top; a raw result's file (and a file on the Browse screen) opens its own — play from here, save this one file, browse; a name in a room's list opens the peer sheet. The catalog record page's bar ⋯ is the record's sheet: play, save, add every track to a playlist as streams, the artist — and the same sheet is a long press on any catalog card (the results' Records rail, Home's feed, the Gaps shelf). The now-playing sheet's lyrics face has its tools on a long press or the link under the words (lyrics.svelte.js, the desktop's): the offset nudged half a second at a time and kept for the track, another LRCLIB candidate chosen when the words are wrong, the lookup forgotten and done again. A record's sheet re-fetches its cover (the desktop's per-record refresh); You → Library clears the play history behind a confirm. The now-playing sheet's ⋯ on a streamed track is the desktop's Save menu: Save to library (at once when the cache is byte-complete, else through the download pipeline) and Save this record for the streamed record the queue holds; beside them, "Copy to a folder…" writes that same cached stream into a folder picked on this phone, as the plain file it is (copy_stream_to_folder — no re-download, no tagging, no library row; a track that has not finished streaming is counted back, never fetched). A moment after boot the phone warms the library's thumbnails the desktop's way (library_art_prewarm at the cards' edge, one decode at a time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or MP3 320+), and the lyrics sheet has the desktop's paste door (plain text, or LRC with timestamps, kept for the track). The rest of the lyrics tools came with it. The sheet carries the per-track auto-sync switch (lyrics_set_auto_sync), so the timing correction the app makes from a file's own lead-in can be turned off for one recording and back on. "Re-time by hand" opens the tap-to-sync room (SyncRoom.svelte, one more layer the back gesture peels): the song plays, the line to time sits large in the middle of the screen with the one behind and the one ahead faint around it, and a tap anywhere stamps it — the last tap undone, −5 s and play/pause under the thumb, and Save writing the track's own document (lyrics_save_lines, the top of the waterfall, where a paste lands too). Lines never tapped are left out of it and the button says how many are kept; a set left mid-tap by the back gesture is offered back by the row that opened it. "Play with reduced vocals" is the desktop's offline DSP (lyrics_prepare_instrumental — a band-limited centre cancel, not a separator, and the wording stays honest about that): the copy plays under the same title, artist and duration, so the lyric identity is still the recording's and the words keep following it, and one row puts the original back. One switch the desktop never needed: You → Downloads → "Downloads on Wi-Fi only" (network.svelte.js, off by default) holds every Save — a record, a track, a folder, a file, a playlist's bulk, the now-playing Save — while the phone is on mobile data (navigator.connection.type); streaming is never held back. The nav wears two badges the desktop's chrome has: transfers in flight on You (the title bar's download ring) and unread chats on Community. The Library's count line carries the collection's hours and the sort offers By format (lossless by depth and rate first, then by bitrate); the empty Search page shows Jump back in, the desktop's. The Library's cards wear the desktop's file stats (library_album_stats, one call for the whole library: FLAC 24/96, MP3 320k, the size on the list rows and the record page), and the artist page shows this phone's own listening (artist_play_stats: time played as a chip, the most played track under "On this phone"). Popular is asked on its own as well as with the page (catalog_artist_popular: the Last.fm ranking and its counts, waiting on nothing but the linked MBID, so the section can paint before the full page is built — and an artist whose page came back with no Popular at all still gets one), and a catalog record's rows that the album's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the desktop's deferred pass by their OWN Deezer artist id (catalog_track_listeners). An artist's photo is Kyle's to choose: the artist page's ⋯ offers Choose hero photo (the phone's image picker, then set_artist_hero — pick_artist_hero's install step split out and shared, so the desktop's dialog keeps its own door; 1920×800 and landscape or it is refused) and Remove custom hero, which puts the catalog's own photos back; the page is asked again either way, so the banner, the ground and the accent all follow. The desktop's own chooser has had no home since its pill left the glass bar — the phone has one first. You → Album artwork carries Refresh artist portraits (refresh_artist_art), the desktop Settings row. A data: URL is the image itself in art.svelte.js now — a chosen hero comes back from Rust as one, and the thumbnail fetch used to throw it away. Search corrects an obvious misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule, a word only when a real name is within two edits.

Tapping About this user on the peer sheet opens the stranger in full (screens/Peer.svelte, push("peer", { username })): the card their own client serves (slsk_user_info — what they wrote about themselves, their picture, whether a slot is free, the queue, how much they have served), the likes and hates they declared to the server (slsk_user_interests), and the comparison this phone makes of their share against the library (overlap.rs: overlap_compute browses them on purpose, overlap_list reads what was compared before, overlap_forget drops it). Nothing is uploaded to work it out — their folder names against your albums, here. The comparison's rows are the reason to open a stranger: every record they have by an artist in your library that you don't, each one opening the catalog search that finds it. Their picture opens in the artwork viewer, which takes a data: key now as well as a library path and an https URL.

Playlists

The desktop's two playlist pages (src/views/Playlists.svelte, Playlist.svelte), at phone scale, on the same commands (list_playlists, playlist_items, playlist_cover, create / rename / describe / pin / duplicate / delete, playlist_add_items, playlist_remove_items).

  • The shelf is Library → Playlists (src/mobile/PlaylistShelf.svelte): the stat line, Pinned then All, the custom cover or the composed mosaic on every card (fetched once per revision — coverFor in playlists.svelte.js), a Setlist / N unresolved badge, New playlist (a prompt sheet). A long press on a card is its sheet: Play, Shuffle, Pin, Rename, Duplicate, Delete (a confirm sheet).
  • The page (screens/Playlist.svelte) is the record page's shape: the cover or mosaic, the kind and a Pinned chip, the description, the meta line with its warnings (N unresolved, N missing), Play / Shuffle / More, a filter, and the rows with the desktop's tags — unresolved, set audio, file missing. Setlist cue times show only when every row carries one. The bar's More is the page's sheet: Pin, Rename, Edit description, Duplicate, Remove unresolved entries, Delete.
  • Playing follows the desktop's queueableRows: Play queues the rows that can play — present local files as themselves, stream rows as path-less entries with their catalog identity, which the player resolves on reach through hooks.resolveEntry (the same path a record's later tracks take, so the three-ahead preload covers them). A tap on a weak row with a name streams it one-off; a bare placeholder opens its sheet. The playing row is found by plItemId on the queue entry. A set-recording slice starts at its cue (t.startSecs) and the player moves on at the next cue (t.endSecs); the scrubber still shows the whole recording.
  • A row's sheet (pltrack): Play from here, Play next, Add to queue, Save to library for a stream row, Find a local match for a row without a file (the desktop's repair panel as a sheet: the library searched by the row's name, a pick relinks the row through playlist_relink_item; an unknown placeholder adopts the track's identity), Go to artist, Remove from playlist; Move up, Move down and Move to the top reorder without a drag (playlist_set_order, the whole order in one call, as the desktop's drop does). A removal is undoable for seven seconds from the neutral toast (notice.svelte.js — the desktop's undo pill); the stash pins the playlist it came from.
  • Add to library / Download all tracks on the page's sheet — the desktop's bulk actions (runBulk in playlists.svelte.js, the rules verbatim): the first files every already-streamed track whose cache is byte-complete, the second finds a source for every stream row (the remembered one, else the ladder) and hands it to the download pipeline; the page shows the run's line with Stop, then its summary.
  • The cover: Choose cover photo on a playlist's sheet opens the phone's image picker (the storage plugin's pick_image: the picked content is copied into the app's cache, and playlist_cover_set installs it the way the desktop's dialog pick does — squared, the master cover, the row's revision moved); Remove custom cover brings the mosaic back.
  • Add to playlist is on every track and record sheet: a pick-a-playlist sheet (New playlist at the top). A file on the phone becomes a local row; a streamed or catalog track keeps its identity (track_id) and cover URL and becomes a stream row.
  • Every mutation re-lists through soulseek:playlists-changed, which the desktop's commands already emit; the page re-reads its rows when the playlist's revision moves.
  • Importing (importer.svelte.js, screens/Import.svelte): the shelf's Import opens four doors — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer; a YouTube playlist link rides this one too, the backend dispatches it by host), a pasted tracklist (plain lines, a timestamped setlist, or track links), a YouTube DJ set's posted tracklist, and a folder of audio files on this phone. A paste holding even one track link takes the link path (playlist_import_track_links, the desktop's own detector verbatim): each line is read off its own public page — the way past Spotify's 100-track public ceiling — and the wait screen counts them as they come. The folder door picks with the storage plugin's document-tree picker and hands the resolved path to playlist_import_folder, which grew a path argument for it: on the phone that command had no dialog to open and always answered nothing. The backend stages the draft over plain HTTP (the same commands as the desktop, no yt-dlp involved), the review screen shows every row's verdict, the stream rows are verified against the catalog while it is up (the desktop's pass, verbatim), and Create swaps the review for the playlist it made (nav.replace).
  • The share sheet (crates/tauri-plugin-share, share.svelte.js): Share → SoulStream from any app. The plugin's ShareActivity (an ACTION_SEND text/plain target, merged into the app's manifest from the plugin module) keeps the text and brings the app's own task to the front through its launcher intent; the WebView asks pending_share at boot and whenever it comes back to the front, and a link opens the link door (the first URL in the text — Spotify shares the link inside a sentence), anything else the pasted-tracklist door. The phone walk sends the intent itself (adb shell am start -a android.intent.action.SEND …).

Not on the phone yet: the playlist-file door and export — both want a file picker the storage plugin has still to grow — the listener door (it needs yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.

Settings that reach the config

set_settings takes camelCase arguments (Tauri's rule for command parameters): { volumeNormalization, downloadDir, streamSourcePrefs, downloadSourcePrefs }. The phone's first You screen sent snake_case keys and every save was silently ignored (found 2026-09-13 while adding the Sources group — the walk now flips the switch and a chip and reads each back from the config). You → Sources is the desktop's Source quality section: per mode (streaming, downloads) the ceiling (no limit / lossy only), the floor (any … lossless only) and the priority (best quality / fastest transfer); the resolver re-reads them (refreshSourcePrefs). You → Streaming is the desktop's Streaming section: how long the stream cache keeps a track after its last play (or Forever — everything streamed goes to the library) and the cache's size limit. Downloads (the album folder format), Sharing (the library folder is always shared, the upload slots, extra shared folders picked on the phone), Lyrics (word-by-word fill) and Album artwork (canonical vs the uploader's art, the embedded cover size, the folder cover, re-fetch all covers) follow the same sections of the desktop's Settings. Not on the phone: the account (sign-in lives on Community), the networks, updates (the APK comes from the build), the lyric alignment models (a desktop runtime), artist artwork's upscaler. Search's empty page lists the recent searches this phone used (the last eight: Enter, or the screen left with a query in it), kept on the device.

Sharing carries one thing the desktop never exposed at all: the listening port (listenPort), what other peers dial when they download from you. Unset means the client's own 50300 (DEFAULT_LISTEN_PORT; bind walks forward a few ports if it is taken); the app asks the router to forward it over UPnP and falls back to the server relaying the connection, and a change applies on the next connect — on mobile data the carrier's NAT blocks incoming connections whatever the number is. set_settings only assigns a port that is Some, so it can be changed but never taken back to unset: the phone walk saves a spare number and then 50300, which is the port an unset config already used.

The dev flavour gets one more row under This phone: Diagnostics (screens/Diagnostics.svelte), the desktop's Ctrl+Shift+D overlay as a screen — process and system CPU, memory, the network rates, the track playing with its loudness correction, the tracks the pre-buffer is fetching, and how big the play history has grown (debug_stats, play_row_count), polled once a second while it is open and never otherwise. devBuild in util.js is the gate: Vite's own DEV flag, true only for the build that loads from the dev server over the cable, so the embedded build the phone keeps has no row to tap. The GPU counters are Windows PDH and the private commit is a Win32 call, so neither is on the screen — off Windows they report None and 0.

Background playback and the lock-screen card

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

crates/tauri-plugin-media-session is a Tauri mobile plugin: build.rs hands its android/ Gradle module to tauri-build (which writes it into gen/android/tauri.settings.gradle), src/lib.rs registers the Kotlin class and exposes update / clear, and the Kotlin side is two files — the plugin (arguments in, the action event out) and MediaService, a mediaPlayback foreground service holding a MediaSessionCompat and a MediaStyle notification. Playing → startForeground; paused → the service steps back and the card stays, dismissable. Android 13+ asks for POST_NOTIFICATIONS the first time something plays. Audio focus is requested while playing (loss pauses, a transient loss pauses and resumes), and headphones coming out pause. The app's capability grants media-session:default; the plugin is registered on every platform so the permission resolves everywhere, and on the desktop its commands answer "not here" (the desktop has smtc.rs).

A trap: tauri-build regenerates gen/android/tauri.settings.gradle and app/tauri.build.gradle.kts — the lines that include every plugin's Android module — ONLY when TAURI_ANDROID_PROJECT_PATH names the project, which the Tauri CLI sets and a hand-driven cargo build does not. Without it the plugin compiled, the APK packaged without its Kotlin, and the app died at start-up with a ClassNotFoundException. src-tauri/android/build.sh exports it; after adding a plugin, touch src-tauri/build.rs too, so the app's build script re-runs and rewrites the wiring.

Performance

tests/mobile/perf.mjs logs the frames the WebView produces through scripted scenes (a long scroll, Home's scroll, a record push, the player opening, idle playback) under CSS variants injected live, so an effect's cost is measured rather than guessed. On the Pixel 6a the frosted chrome — backdrop-filter on the nav, the mini bar and the glass bar — turned Home's scroll from 60 Hz to 30 Hz (16 of 129 frames over 32 ms; 0 without) and dropped every other frame of the player's opening (12 → 0). The ambience blur, the hero's blur and the entrance springs cost nothing measurable. So the phone's chrome is solid by default and the blur is a switch (You → Appearance → Glass blur). Re-run the probe before adding any backdrop blur to the phone; --quick runs the base variant only, and the app must be on the phone's screen.

The audio side had its own lag: dumpsys media.audio_flinger showed the track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer above moved it to the normal mixer; the phone walk samples the underrun counters before and after and fails if they rose.

Prerequisites (home machine, done 2026-09-12)

  • Android SDK at %LOCALAPPDATA%\Android\Sdk: command-line tools, platform-tools, platforms 34–36, build-tools 35/36, NDK 28.2.13676358, cmake 3.31.6. Installed with sdkmanager alone — no Android Studio.
  • User environment: ANDROID_HOME, NDK_HOME, ANDROID_NDK_HOME, JAVA_HOME=C:\Program Files\Java\jdk-17; PATH has platform-tools and cmake. (A bash session may not see them; the scripts below set their own.)
  • Rust targets: aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android.
  • The Tauri CLI here is npm's: npx tauri … (there is no cargo tauri).
  • Windows Developer Mode (Settings → System → For developers). Tauri symlinks the built library into gen/android/app/src/main/jniLibs; Windows refuses symlinks without it. Until it is on, use the detour below.
  • No emulator: virtualization is off in this machine's firmware (MSI B550 Tomahawk: OC → Advanced CPU Configuration → SVM Mode) and Windows Hypervisor Platform is off. Builds go to a real phone over USB.

Build and run

npx tauri android build --debug --target aarch64 --apk

With Developer Mode off, the CLI builds the Rust side and then fails at the symlink. The detour, scripted:

bash src-tauri/android/deploy.sh     # vite → cargo → strip → gradle → adb install → launch → screenshot
bash src-tauri/android/build.sh      # the build alone; APK path printed at the end

What the scripts do, by hand:

# 0. the frontend, then the library — with tauri's custom-protocol feature ON.
#    Without it the build embeds the dev URL (http://localhost:1421) instead of
#    dist/, and the phone shows "Failed to request http://localhost:1421/mobile.html".
npm run build
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol
#    (with CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER / CC_ / CXX_ / AR_aarch64_linux_android
#     pointing at the NDK's windows-x86_64/bin aarch64-linux-android26-clang(.cmd) wrappers)
# 1. strip the library's debug info (607 MB → 50 MB; the debug packaging keeps symbols)
cp target/aarch64-linux-android/debug/libapp_lib.so src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/
"%LOCALAPPDATA%\Android\Sdk\ndk\28.2.13676358\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe" --strip-debug src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libapp_lib.so
# 2. package with Gradle, skipping the task that would redo the symlink
cd src-tauri/gen/android && ./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug

APK: src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk. A frontend-only change still needs the cargo step: dist/ is embedded in the library at compile time (the app crate alone recompiles, ~1 min).

Phone (USB debugging on, prompt accepted):

adb devices
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
adb shell am start -n com.soulstream.app/.MainActivity
adb logcat --pid=$(adb shell pidof com.soulstream.app)     # Rust logs land under RustStdoutStderr / app_lib::*
adb exec-out screencap -p > shot.png
adb shell input tap X Y        # drive it: 1080×2400 px, 2.625 px per dp
adb shell input keyevent KEYCODE_BACK

Music for a test library goes in as the app (no root needed on a debug build):

adb push "F:\Music\SoulSeek Music\<Artist>\<Album>" /data/local/tmp/ssm/
adb shell "chmod -R a+rX /data/local/tmp/ssm; run-as com.soulstream.app sh -c 'mkdir -p \"Library/<Artist>\" && cp -r \"/data/local/tmp/ssm/<Album>\" \"Library/<Artist>/\"'"
adb shell am force-stop com.soulstream.app    # the scan runs at launch

Iterating without cargo (the dev flavour)

Every embedded build pays ~2 min of cargo for a CSS change, because dist/ is compiled into the library. The dev flavour skips that: the same detour build without tauri/custom-protocol, so the library embeds the dev URL (http://localhost:1421), and adb reverse tcp:1421 tcp:1421 makes the phone's localhost:1421 this machine's Vite dev server over the USB cable — no LAN address, no firewall hole, hot reload on the phone. Cargo runs only when Rust changes. Both flavours share target/: a switch recompiles tauri and the app crate once (~3 min, the leaf crates stay fresh).

bash src-tauri/android/dev.sh          # build the dev library (when Rust changed), install, tunnel, launch
npm run dev                            # or the Browser pane's "frontend" config — the phone loads from it
bash src-tauri/android/dev.sh --no-build   # after a reconnect: reinstall/tunnel/launch without cargo
bash src-tauri/android/deploy.sh       # the embedded build again — what the phone keeps when the cable goes

The dev APK needs this machine and the cable; put the embedded build back before the phone leaves. The toolchain env both scripts share is src-tauri/android/env.sh. Verified 2026-09-13: a shell.css edit reached the phone in under a second. The page's origin stays http://tauri.localhost — Tauri proxies the dev server through its own scheme on mobile — so the harness and the app see one origin in both flavours.

Verifying

node tests/mobile/smoke.mjs                          # headless Chrome + the mock library
node tests/mobile/smoke.mjs --phone --album "Spiderman"   # the real app on the connected phone

The mock walk builds dist-smoke/, switches the mock library on (src/lib/mockLibrary.js: 140 records, some without art on purpose, and a fake network behind the same switch — a stored account that signs in at boot, rooms with a few lines in them, a catalog with one artist, a search answered by five peers, a stream that arrives over twelve seconds with its waveform growing; backend events go over the mock's own bus, which src/lib/events.js puts in front of Tauri's listen), drives Home → a long press → Search → Library and its tabs → a record → its sheets → the player's three faces → the back gesture → an artist page → Community → a room → a catalog search → a song through the resolver → the lane rising in the sheet → a catalog record, its Save and the Transfers burst → Gaps → the playlist shelf and a page with every row kind (a local row, the stream row through the resolver, the filter, a removal and its undo, unpin, rename, a new playlist, Add to playlist from a record's sheet, delete) → the import doors (a pasted tracklist, a link with its catalog verification, pasted track links read a page each, a folder of audio files off the phone, a share from another app) → the playlist repair (an unknown row relinked from the library) → the artwork viewer → a queue row's sheet → a catalog song's, a catalog card's and the record page's sheets → the now-playing Save → a peer's sheet, a raw file's sheet, a user's shares browsed and a folder of theirs streamed → a buddy's sheet → You (every switch and chip saved and read back from the config) → recent searches and a spelling suggestion → the sleep timer, asserts what each step should show, screenshots every step into tests/mobile/out/, and fails on any exception or console error. The mock answers every command the phone uses; one it does not know is named in the report ([mock] no mock for X) rather than surfacing as a TypeError toast, console warnings carry their top frames (the smoke bundle is built unminified for that), and every probe carries the history mirror — the entry's depth against the layers the app says the back gesture can peel (hd/hwant; a drift is how the blank page after a confirmed delete was found).

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; every CDP call times out after 45 s and the reload step re-attaches to the page, since a WebView that stops answering once hung a walk for ten minutes; its audio check counts SoulStream's OWN tracks' underruns from the mixer dump — the thread-wide counters move with any app's notification sound, and sent the walk chasing dropouts that were not ours): a debug build's WebView exposes DevTools on localabstract:webview_devtools_remote_<pid>, adb forward puts it on a local port, and the steps run against the real library — real playback, real covers, the colour following the record — with the phone's own frames (adb screencap) as the stills. It refuses to run when another app is on the phone's screen (it never takes the phone from whoever is holding it), and a WebView in the background does not answer, so the app has to be in front. One of its steps is location.reload(): the audio thread keeps playing and the session comes back live, which is the reload half of session restore tested for real. After the steps it force-stops and relaunches the app (the restart half: paused at the position, play resumes), checks the system's media session, sends media keys, backgrounds the app for ten seconds, and — with the app signed into Soulseek — types --query into Search, taps the first catalog song and watches it resolve and stream (the peer, the phase, the coverage), then asks for the raw peers. --lockscreen takes the lock-screen card's still last, because it leaves the phone locked; --only-lock takes just that still of whatever is on the bar (resuming it if paused) without the walk.

Not done yet

  • Community's private layer (Spaces, holdings, the sealed inbox, friends by key, Who has it) — Freenet on the phone is a later experiment; the network side is in. Rooms and chat are built and mock-walked; a live room is Kyle's to join.
  • The desktop-only doors and tools: the playlist-file import and export (a file picker the phone's storage plugin has still to grow), the listener door and the lyric alignment models (a desktop runtime), drag reorder (Move up / down / to the top stand in), catalog track previews, the full discography page, the artist-artwork upscaler, updates and the account section (sign-in lives on Community).
  • Follows and the play history are per library folder, so the phone's own folder starts empty of both; they arrive with a shared library.
  • The artwork viewer: the cover at full size, pinch to zoom, one finger pans while zoomed, a tap on the unzoomed cover or the back gesture closes.