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

+251 -1
View File
@@ -97,7 +97,6 @@ export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
if (cmd === "followed_artists") return Promise.resolve([]);
if (cmd === "recent_albums") return Promise.resolve([]);
if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
@@ -107,5 +106,256 @@ export function mockInvoke(cmd, args) {
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
}
if (cmd === "list_playlists") return Promise.resolve([]);
// The track index, as the library crate shapes it (TrackIndexRow) — the
// phone's Tracks tab and its library search read these.
if (cmd === "library_track_index" || cmd === "library_track_search") {
const rows = albums.flatMap((a) =>
tracksFor(a.id).map((t) => ({
title: t.title, artist: a.artist, album: a.title,
duration_secs: t.duration_secs, path: t.path, art_path: a.art_path,
})));
if (cmd === "library_track_index") return Promise.resolve(rows);
const words = String(args?.query || "").toLowerCase().split(/\s+/).filter(Boolean);
return Promise.resolve(words.length
? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40)
: []);
}
return mockCatalog(cmd, args) ?? mockNet(cmd, args);
}
/* ── the fake network ───────────────────────────────────────────────────
A Soulseek behind the phone's screens: a stored account that signs in at
boot, a room list, rooms with a few lines in them, a search answered by
five peers, and a stream that arrives over twelve seconds with its
waveform growing — enough to walk Search, Community and the rising lane
in a plain browser. Events go out over the bus below; the harness can
fire its own through window.__ssMock.emit. */
const bus = new Map();
/** Returns a Promise<unlisten> for a mocked page, or undefined to pass through. */
export function mockListen(event, cb) {
if (!enabled) return undefined;
let set = bus.get(event);
if (!set) { set = new Set(); bus.set(event, set); }
set.add(cb);
return Promise.resolve(() => { set.delete(cb); });
}
export function mockEmit(event, payload) {
for (const cb of bus.get(event) || []) {
try { cb({ event, payload }); } catch (e) { console.error(e); }
}
}
const later = (ms, fn) => setTimeout(fn, ms);
if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit };
const MOCK_USER = "mockuser";
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23]];
const PEOPLE = ["ada", "bo", "cy", "dee"];
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
const PEERS = [
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
{ username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 },
{ username: "bo", slot: false, speed: 1500, queue: 3, ext: "flac", depth: 16, rate: 44100 },
{ username: "mp3_marge", slot: true, speed: 500, queue: 0, ext: "mp3", bitrate: 320 },
{ username: "slowpoke", slot: false, speed: 60, queue: 12, ext: "mp3", bitrate: 192 },
];
const TITLES = ["One More Time", "Aerodynamic", "Digital Love", "Harder, Better, Faster, Stronger", "Crescendolls", "Nightvision", "Superheroes", "High Life", "Something About Us", "Voyager", "Veridis Quo", "Short Circuit", "Face to Face", "Too Long"];
function peerResponse(token, peer, query) {
const artist = /daft/i.test(query) ? "Daft Punk" : (query.split(/\s+/)[0] || "Various");
const album = /discovery/i.test(query) ? "Discovery" : "Homework";
const folder = "@@shared\\Music\\" + artist + "\\" + artist + " - 2001 - " + album + (peer.ext === "flac" ? " [FLAC]" : " [MP3 " + peer.bitrate + "]");
const files = TITLES.map((t, i) => {
const name = String(i + 1).padStart(2, "0") + " - " + t + "." + peer.ext;
const duration = 200 + ((i * 37) % 180);
const size = peer.ext === "flac" ? Math.round(duration * (peer.depth === 24 ? 320000 : 110000)) : Math.round(duration * peer.bitrate * 125);
return { path: folder + "\\" + name, name, folder, size, extension: peer.ext, bitrate: peer.bitrate ?? null, duration, sample_rate: peer.rate ?? 44100, bit_depth: peer.depth ?? null, vbr: false };
});
return { token, username: peer.username, has_free_upload_slot: peer.slot, upload_speed: peer.speed, queue_length: peer.queue, files };
}
/* the catalog: one artist, two records, the songs of one of them */
const CAT_ARTIST = { id: 27, name: "Daft Punk", picture: svgArt(210, "DP"), album_count: 4, fans: 4200000 };
const CAT_NAMESAKE = { id: 28, name: "Daft Punk", picture: "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg", album_count: 1, fans: 120 };
const CAT_ALBUMS = [
{ id: 302127, title: "Discovery", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 14, edition_group: 302127, is_soundtrack: false },
{ id: 302128, title: "Homework", artist: "Daft Punk", cover: null, release_date: "1997-01-20", record_type: "album", track_count: 16, edition_group: null, is_soundtrack: false },
];
const CAT_TRACKS = TITLES.map((t, i) => ({
id: 3135550 + i, title: t, artist: "Daft Punk", artist_id: 27, rank: 900000 - i * 1000,
album: "Discovery", album_id: 302127, cover: null, duration_secs: 200 + ((i * 37) % 180), preview: null, track_no: i + 1,
}));
const CAT_DISCO = [
...CAT_ALBUMS,
{ id: 302129, title: "Discovery (Deluxe Edition)", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 16, edition_group: 302127, is_soundtrack: false },
{ id: 302130, title: "Alive 1997", artist: "Daft Punk", cover: null, release_date: "2001-10-01", record_type: "album", track_count: 1, edition_group: null, is_soundtrack: false },
{ id: 302131, title: "One More Time", artist: "Daft Punk", cover: null, release_date: "2000-11-13", record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
{ id: 302132, title: "Musique Vol. 1 1993–2005", artist: "Daft Punk", cover: null, release_date: "2006-04-03", record_type: "compile", track_count: 15, edition_group: null, is_soundtrack: false },
{ id: 302133, title: "TRON: Legacy (Original Motion Picture Soundtrack)", artist: "Daft Punk", cover: null, release_date: "2010-12-03", record_type: "album", track_count: 22, edition_group: null, is_soundtrack: true },
{ id: 302134, title: "Human After All", artist: "Daft Punk", cover: null, release_date: "2005-03-14", record_type: "album", track_count: 10, edition_group: null, is_soundtrack: false },
{ id: 302135, title: "Random Access Memories", artist: "Daft Punk", cover: null, release_date: "2013-05-17", record_type: "album", track_count: 13, edition_group: null, is_soundtrack: false },
];
/* the follows feed needs covers and dates near today */
const dayIso = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
for (const a of CAT_DISCO) a.cover = svgArt((a.id * 31) % 360, a.title[0]);
CAT_DISCO.push(
{ id: 302136, title: "Infinity Repeating", artist: "Daft Punk", cover: svgArt(300, "I"), release_date: dayIso(-9), record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
{ id: 302137, title: "Alive 2027", artist: "Daft Punk", cover: svgArt(120, "A"), release_date: dayIso(21), record_type: "album", track_count: 12, edition_group: null, is_soundtrack: false },
);
const NEWS = [
{ title: "Daft Punk announce Alive 2027 — a live record from the reunion shows", url: "https://example.com/news/1", source: "Pitchfork", date: Math.floor(Date.now() / 1000) - 3600 * 5 },
{ title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 },
];
const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));
let follows = [];
function mockCatalog(cmd, args) {
if (cmd === "catalog_artist") {
const id = Number(args?.artistId);
const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST;
return new Promise((r) => setTimeout(() => r({
artist: a, banner: null, banners: [], banner_custom: false, logos: [],
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
top_genre: "Electronic", socials: [],
}), 220));
}
if (cmd === "catalog_artist_socials") {
const id = Number(args?.artistId);
return new Promise((r) => setTimeout(() => r(id === CAT_ARTIST.id ? [{ kind: "website", url: "https://example.com" }, { kind: "wikipedia", url: "https://en.wikipedia.org/wiki/Daft_Punk" }] : []), 300));
}
if (cmd === "home_warm") return Promise.resolve();
if (cmd === "home_artist_news") return new Promise((r) => setTimeout(() => r(Object.fromEntries((args?.artists || []).map((n) => [n, /daft/i.test(n) ? NEWS : []]))), 150));
if (cmd === "catalog_artist_identity") return Promise.resolve({ artist_id: Number(args?.artistId), albums: [], socials: [], type_hints: [], owned_album_ids: [] });
if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] });
if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement.");
if (cmd === "catalog_related") return Promise.resolve(RELATED);
if (cmd === "followed_artists") return Promise.resolve(follows.slice());
if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
if (cmd === "catalog_search") {
const q = String(args?.query || "").toLowerCase();
const hit = /daft|discovery|one more|punk/.test(q);
return new Promise((r) => setTimeout(() => r(hit
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
: { artists: [], albums: [], tracks: [] }), 120));
}
if (cmd === "catalog_album") {
const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId));
if (!a) return Promise.reject(new Error("catalog_album: unknown album"));
return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });
}
if (cmd === "catalog_album_info") {
const t = String(args?.title || "");
return new Promise((r) => setTimeout(() => r(/discovery/i.test(t)
? { description: "Discovery is the second studio album by the French electronic music duo Daft Punk, released on 12 March 2001. It marks a shift from the Chicago house of their debut to a house style more heavily inspired by disco, post-disco, garage house and synth-pop. The album's singles included the worldwide hit One More Time.", genre: "Electronic", label: "Virgin", source: "wikipedia" }
: null), 150));
}
if (cmd === "download_file") {
const id = ++downloadSeq;
const total = args?.size || 20e6;
const name = String(args?.path || "").split("\\").pop();
const base = { id, username: args?.username, path: args?.path, name, queue_place: null, local_path: null, error: null };
later(200, () => mockEmit("soulseek:download", { ...base, state: "connecting", received: 0, total }));
let got = 0;
const step = setInterval(() => {
got = Math.min(total, got + total / 8);
mockEmit("soulseek:download", { ...base, state: got >= total ? "complete" : "transferring", received: Math.round(got), total, local_path: got >= total ? "C:\\mock\\library\\" + name : null });
if (got >= total) { clearInterval(step); mockEmit("soulseek:library-changed", {}); }
}, 400 + (id % 3) * 120);
return Promise.resolve(id);
}
if (cmd === "cancel_download") return Promise.resolve();
if (cmd === "rescan_library") return Promise.resolve();
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
if (cmd === "preload_stream") {
const path = args?.path;
if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); }
return Promise.resolve();
}
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
if (cmd === "get_settings") return Promise.resolve({ download_dir: "C:\\mock\\Library" });
if (cmd === "set_settings") return Promise.resolve();
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
return undefined;
}
let token = 0;
let downloadSeq = 0;
const cacheReady = new Set(); // paths a preload has fully fetched
const preloading = new Set();
let xfer = null; // the one fake transfer: { received, total, timer }
let posTimer = null; // the fake engine's position ticks
function mockNet(cmd, args) {
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
if (cmd === "connect_stored" || cmd === "connect_and_login") {
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
return Promise.resolve();
}
if (cmd === "disconnect") { later(50, () => mockEmit("soulseek:state", { kind: "disconnected" })); return Promise.resolve(); }
if (cmd === "forget_credentials") return Promise.resolve();
if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]);
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username));
if (cmd === "slsk_room_list") {
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
return Promise.resolve();
}
if (cmd === "slsk_join_room") {
const room = args?.room;
later(250, () => mockEmit("soulseek:room-joined", { room, users: [...PEOPLE, MOCK_USER].map((username) => ({ username })) }));
LINES.forEach((message, i) => later(400 + i * 350, () => mockEmit("soulseek:room-message", { room, username: PEOPLE[i % PEOPLE.length], message, ts: Math.floor(Date.now() / 1000) })));
return Promise.resolve();
}
if (cmd === "slsk_leave_room") { later(50, () => mockEmit("soulseek:room-left", { room: args?.room })); return Promise.resolve(); }
if (cmd === "slsk_say_room") {
later(120, () => mockEmit("soulseek:room-message", { room: args?.room, username: MOCK_USER, message: args?.message, ts: Math.floor(Date.now() / 1000) }));
return Promise.resolve();
}
if (cmd === "slsk_message_user") {
later(900, () => mockEmit("soulseek:private-message", { username: args?.username, message: "sure, sending", timestamp: Math.floor(Date.now() / 1000) }));
return Promise.resolve();
}
if (cmd === "start_search") {
const t = ++token;
const q = String(args?.query || "");
later(300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[1], q)]));
later(700, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[0], q), peerResponse(t, PEERS[3], q)]));
later(1300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[2], q), peerResponse(t, PEERS[4], q)]));
return Promise.resolve(t);
}
if (cmd === "stop_search") return Promise.resolve();
if (cmd === "play_stream") {
if (xfer) clearInterval(xfer.timer);
clearInterval(posTimer);
const total = args?.size || 30e6;
const duration = args?.duration || 240;
xfer = { received: 0, total, timer: null, path: args?.path };
xfer.timer = setInterval(() => {
xfer.received = Math.min(total, xfer.received + total / 30);
const done = xfer.received >= total;
mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null });
if (done) clearInterval(xfer.timer);
}, 400);
let secs = 0;
later(1200, () => {
mockEmit("soulseek:player", { kind: "playing" });
posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500);
});
return Promise.resolve();
}
if (cmd === "stream_waveform") {
const coverage = xfer ? xfer.received / xfer.total : 0;
const n = Math.round((args?.buckets || 900) * coverage);
const peaks = Array.from({ length: n }, (_, i) => 0.25 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.011)));
return Promise.resolve({ coverage, peaks });
}
if (cmd === "stream_cache_len") return Promise.resolve(xfer ? Math.round(xfer.received) : 0);
if (cmd === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null);
if (cmd === "record_play") return Promise.resolve(1);
if (cmd === "add_play_seconds") return Promise.resolve();
return undefined;
}