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

+123
View File
@@ -0,0 +1,123 @@
/* The library, as the phone reads it: albums from library.db, the recents
from the play history, follows and playlists. One store, loaded once at
launch; the pieces that change (recents) are re-read when they do. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const lib = $state({
albums: [],
recents: [], // library rows only, with an album_id
follows: [],
playlists: [],
version: "",
username: null,
loaded: false,
error: "",
});
const tracksCache = new Map(); // album id -> Promise<Track[]>
/** An album's tracks, cached for the session (a record's rows do not
* change under the phone). */
export function tracksOf(id) {
if (!tracksCache.has(id)) {
tracksCache.set(
id,
api.albumTracks(id).catch((e) => { tracksCache.delete(id); throw e; }),
);
}
return tracksCache.get(id);
}
export function albumById(id) {
return lib.albums.find((a) => a.id === id) || null;
}
/** Newest first — "recently added" everywhere. */
export function newest() {
return lib.albums.slice().sort((a, b) => b.added_at - a.added_at);
}
/** An artist's records, newest year first. */
export function albumsBy(artist) {
const k = String(artist || "").toLowerCase();
return lib.albums
.filter((a) => a.artist.toLowerCase() === k)
.sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at);
}
/** Every artist in the library, biggest first, with the portrait the
* library already holds (artist_art_path) when it has one. Casing splits in
* the tags ("King Gizzard & The/the Lizard Wizard") merge here. */
export function artists() {
const m = new Map();
for (const a of lib.albums) {
const k = a.artist.toLowerCase();
let e = m.get(k);
if (!e) { e = { name: a.artist, albums: 0, tracks: 0, art: null, cover: null }; m.set(k, e); }
e.albums++;
e.tracks += a.track_count || 0;
if (!e.art && a.artist_art_path) e.art = a.artist_art_path;
if (!e.cover && a.art_path) e.cover = a.art_path;
}
return [...m.values()].sort((x, y) => y.albums - x.albums || y.tracks - x.tracks || x.name.localeCompare(y.name));
}
export function isFollowed(name) {
const k = String(name || "").toLowerCase();
return lib.follows.some((f) => f.name.toLowerCase() === k);
}
export function followedEntry(name) {
const k = String(name || "").toLowerCase();
return lib.follows.find((f) => f.name.toLowerCase() === k) || null;
}
export async function refreshFollows() {
try { lib.follows = (await api.followedArtists()) || []; } catch { /* keep what we had */ }
}
/** Follow or unfollow, by the catalog identity; the list is re-read. */
export async function toggleFollow(artistId, name, portrait = null) {
const cur = followedEntry(name);
if (cur) await api.unfollowArtist(cur.artist_id);
else await api.followArtist(artistId, name, portrait ?? null);
await refreshFollows();
}
export async function refreshRecents() {
try {
const rows = await api.recentAlbums(14);
lib.recents = (rows || []).filter((r) => r.kind === "library" && r.album_id != null);
} catch {
/* the rail simply stays as it was */
}
}
/** A download the pipeline just filed, a rescan, an import: the backend
* says so (soulseek:library-changed) and the albums are re-read. */
let watching = false;
export async function watchLibrary() {
if (watching) return;
watching = true;
let timer = null;
try {
await listen("soulseek:library-changed", () => {
clearTimeout(timer);
timer = setTimeout(async () => {
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
await refreshRecents();
}, 600);
});
} catch { /* not a phone */ }
}
export async function loadLibrary() {
try {
const st = await api.appStatus();
lib.version = st?.version ?? "";
lib.username = st?.connected_as ?? null;
} catch (e) { lib.error = String(e); }
try { lib.albums = await api.libraryAlbums(); } catch (e) { lib.error = String(e); }
await refreshRecents();
try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; }
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
lib.loaded = true;
}