android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour

The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+378
View File
@@ -0,0 +1,378 @@
# 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 |
| `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`, `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 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 — 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.
## 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
```bash
npx tauri android build --debug --target aarch64 --apk
```
With Developer Mode off, the CLI builds the Rust side and then fails at the
symlink. The detour, scripted:
```bash
bash src-tauri/android/deploy.sh # vite → cargo → strip → gradle → adb install → launch → screenshot
bash src-tauri/android/build.sh # the build alone; APK path printed at the end
```
What the scripts do, by hand:
```bash
# 0. the frontend, then the library — with tauri's custom-protocol feature ON.
# Without it the build embeds the dev URL (http://localhost:1421) instead of
# dist/, and the phone shows "Failed to request http://localhost:1421/mobile.html".
npm run build
cargo build --lib -p app --target aarch64-linux-android --features tauri/custom-protocol
# (with CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER / CC_ / CXX_ / AR_aarch64_linux_android
# pointing at the NDK's windows-x86_64/bin aarch64-linux-android26-clang(.cmd) wrappers)
# 1. strip the library's debug info (607 MB → 50 MB; the debug packaging keeps symbols)
cp target/aarch64-linux-android/debug/libapp_lib.so src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/
"%LOCALAPPDATA%\Android\Sdk\ndk\28.2.13676358\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe" --strip-debug src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libapp_lib.so
# 2. package with Gradle, skipping the task that would redo the symlink
cd src-tauri/gen/android && ./gradlew.bat assembleArm64Debug -x rustBuildArm64Debug
```
APK: `src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk`.
A frontend-only change still needs the cargo step: `dist/` is embedded in
the library at compile time (the app crate alone recompiles, ~1 min).
Phone (USB debugging on, prompt accepted):
```bash
adb devices
adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk
adb shell am start -n com.soulstream.app/.MainActivity
adb logcat --pid=$(adb shell pidof com.soulstream.app) # Rust logs land under RustStdoutStderr / app_lib::*
adb exec-out screencap -p > shot.png
adb shell input tap X Y # drive it: 1080×2400 px, 2.625 px per dp
adb shell input keyevent KEYCODE_BACK
```
Music for a test library goes in as the app (no root needed on a debug build):
```bash
adb push "F:\Music\SoulSeek Music\<Artist>\<Album>" /data/local/tmp/ssm/
adb shell "chmod -R a+rX /data/local/tmp/ssm; run-as com.soulstream.app sh -c 'mkdir -p \"Library/<Artist>\" && cp -r \"/data/local/tmp/ssm/<Album>\" \"Library/<Artist>/\"'"
adb shell am force-stop com.soulstream.app # the scan runs at launch
```
## Iterating without cargo (the dev flavour)
Every embedded build pays ~2 min of cargo for a CSS change, because `dist/`
is compiled into the library. The dev flavour skips that: the same detour
build **without** `tauri/custom-protocol`, so the library embeds the dev URL
(`http://localhost:1421`), and `adb reverse tcp:1421 tcp:1421` makes the
phone's localhost:1421 this machine's Vite dev server **over the USB cable** —
no LAN address, no firewall hole, hot reload on the phone. Cargo runs only
when Rust changes. Both flavours share `target/`: a switch recompiles tauri
and the app crate once (~3 min, the leaf crates stay fresh).
```bash
bash src-tauri/android/dev.sh # build the dev library (when Rust changed), install, tunnel, launch
npm run dev # or the Browser pane's "frontend" config — the phone loads from it
bash src-tauri/android/dev.sh --no-build # after a reconnect: reinstall/tunnel/launch without cargo
bash src-tauri/android/deploy.sh # the embedded build again — what the phone keeps when the cable goes
```
The dev APK needs this machine and the cable; put the embedded build back
before the phone leaves. The toolchain env both scripts share is
`src-tauri/android/env.sh`. Verified 2026-09-13: a shell.css edit reached
the phone in under a second. The page's origin stays `http://tauri.localhost`
— Tauri proxies the dev server through its own scheme on mobile — so the
harness and the app see one origin in both flavours.
## Verifying
```bash
node tests/mobile/smoke.mjs # headless Chrome + the mock library
node tests/mobile/smoke.mjs --phone --album "Spiderman" # the real app on the connected phone
```
The mock walk builds `dist-smoke/`, switches the mock library on
(`src/lib/mockLibrary.js`: 140 records, some without art on purpose, and a
**fake network** behind the same switch — a stored account that signs in at
boot, rooms with a few lines in them, a catalog with one artist, a search
answered by five peers, a stream that arrives over twelve seconds with its
waveform growing; backend events go over the mock's own bus, which
`src/lib/events.js` puts in front of Tauri's `listen`), drives Home → a long
press → Search → Library and its tabs → a record → its sheets → the player's
three faces → the back gesture → an artist page → Community → a room → a
catalog search → a song through the resolver → the lane rising in the sheet →
You → the sleep timer, asserts what each step should show, screenshots every
step into `tests/mobile/out/`, and fails on any exception or console error.
Two toasts are expected there and filtered: without Tauri, `listen` and
`invoke` do not exist.
The phone walk is the same driver on the real thing: 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
- Import (the desktop's import flow) on the phone.
- Save and Transfers exist (You → Transfers, fed by `soulseek:download`; a
live row cancels on tap; a long press on a catalog row offers the track's
own Save).
- Community's private layer (Spaces, holdings, the sealed inbox, friends by
key) — Freenet on the phone is a later experiment; the network side is in.
- 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.