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

+383
View File
@@ -0,0 +1,383 @@
<script>
/* Search — the desktop's top bar, on the phone: the CATALOG first
(Deezer, MusicBrainz and Apple behind one field), led by the desktop's
TOP RESULT — the most popular exact match of its kind, an artist, a
record or a song, scored as a share of its own kind's leader — then
artists, records and songs, then what the library holds, then the raw
network, offered rather than run. A song row plays through the
resolver (the source rules pick a peer's copy); a record opens its
page; an artist theirs. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import Cover from "../Cover.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { lib, artists } from "../lib.svelte.js";
import { push, openHalf, patch } from "../nav.svelte.js";
import { player, playIndexRow, albumOfRow, playEntries } from "../player.svelte.js";
import { slsk } from "../slsk.svelte.js";
import { net, search as netSearch, speedLabel, groupFormat } from "../net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { catalog, artistKey, loadProfile, seedFor, lookupArtist, yearOf, fans, picture } from "../catalog.svelte.js";
import { latestOf } from "../disco.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { entry, screenEl = null } = $props();
let q = $state(entry.c.q || "");
let inputEl = $state(null);
/** the screen remembers its query across the tab switch (nav's entry) */
function setQ(v) { q = v; patch({ q: v }); }
const query = $derived(q.trim());
const asking = $derived(query.length >= 2);
/* ── the catalog, asked once the typing pauses ─────────────────── */
let cat = $state(null);
let catBusy = $state(false);
let catErr = $state("");
let catSeq = 0;
let catTimer = null;
$effect(() => {
const qq = query;
clearTimeout(catTimer);
if (qq.length < 2) { cat = null; catBusy = false; catErr = ""; return; }
catBusy = true;
catErr = "";
const my = ++catSeq;
catTimer = setTimeout(() => {
api.catalogSearch(qq).then(
(r) => { if (my === catSeq) { cat = r || { artists: [], albums: [], tracks: [] }; catBusy = false; } },
(e) => { if (my === catSeq) { cat = null; catErr = String(e); catBusy = false; } },
);
}, 350);
return () => clearTimeout(catTimer);
});
const allArtists = $derived(cat?.artists ?? []);
const allAlbums = $derived(cat?.albums ?? []);
const allTracks = $derived(cat?.tracks ?? []);
const catArtists = $derived(allArtists.slice(0, 8));
const catAlbums = $derived(allAlbums.slice(0, 12));
const catTracks = $derived(allTracks.slice(0, 10));
const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
/* ── the top result: the desktop's rule, verbatim ──────────────────
Each candidate scores as a SHARE OF ITS OWN KIND'S LEADER, never as a
raw number — fans and rank measure different things on different
scales. An outright tie falls back to artist → album → song, the
order of how much a page can say about the thing you named; nothing
named outright falls back to each list's own most popular entry. */
const norm = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const rankOf = (t) => t?.rank ?? 0;
const top = $derived.by(() => {
const qn = norm(query);
if (!qn || !cat) return null;
const exact = (s) => norm(s) === qn;
const artist = allArtists.filter((a) => exact(a.name)).sort((a, b) => (b.fans ?? 0) - (a.fans ?? 0))[0] ?? null;
const song = allTracks.filter((t) => exact(t.title)).sort((a, b) => rankOf(b) - rankOf(a))[0] ?? null;
const albumRank = (al) => Math.max(0, ...allTracks.filter((t) => t.album_id === al.id).map(rankOf));
const album = allAlbums.filter((al) => exact(al.title)).sort((a, b) => albumRank(b) - albumRank(a))[0] ?? null;
const maxFans = Math.max(1, ...allArtists.map((a) => a.fans ?? 0));
const maxRank = Math.max(1, ...allTracks.map(rankOf));
const candidates = [
artist && { kind: "artist", data: artist, score: (artist.fans ?? 0) / maxFans, tie: 3 },
album && { kind: "album", data: album, score: albumRank(album) / maxRank, tie: 2 },
song && { kind: "song", data: song, score: rankOf(song) / maxRank, tie: 1 },
].filter(Boolean);
if (candidates.length) {
candidates.sort((a, b) => b.score - a.score || b.tie - a.tie);
return candidates[0];
}
if (allArtists.length) return { kind: "artist", data: allArtists[0] };
if (allAlbums.length) return { kind: "album", data: allAlbums[0] };
if (allTracks.length) return { kind: "song", data: [...allTracks].sort((a, b) => rankOf(b) - rankOf(a))[0] };
return null;
});
const topArt = $derived(top?.kind === "artist" ? picture(top.data.picture) : (top?.data.cover ?? null));
/* only the kind that won pays for its supplement: an artist's profile
(tags, totals) and its page — warmed while the results are read, as
the desktop does, so "Open artist" is instant and the latest release
is the real one (a Deezer SEARCH carries no dates) — or a record's or
song's artist portrait for the byline */
$effect(() => {
const t = top;
if (!t) return;
untrack(() => {
if (t.kind === "artist") { loadProfile(t.data.id); lookupArtist(t.data.name, t.data); }
else seedFor(t.data.artist);
});
});
const profile = $derived(top?.kind === "artist" ? catalog.profiles[top.data.id] ?? null : null);
const heroTags = $derived((profile?.tags ?? []).slice(0, 4));
const byline = $derived(top && top.kind !== "artist" ? catalog.seeds[artistKey(top.data.artist)] ?? null : null);
const topReleases = $derived(top?.kind === "artist" ? allAlbums.filter((al) => norm(al.artist) === norm(top.data.name)) : []);
/** The newest record by the hero artist — the other thing a search about
* an artist is asking. From the artist's own page once it has landed
* (dated); until then nothing, rather than a guess. */
const latestRelease = $derived.by(() => {
if (top?.kind !== "artist") return null;
const pg = catalog.artists[artistKey(top.data.name)]?.page;
return pg ? latestOf(pg.albums) : null;
});
function openArtist(name, seed = null) { push("artist", { name, seed }, screenEl); }
function openAlbum(a) { push("catalogAlbum", { id: a.id, artist: a.artist, title: a.title }, screenEl); }
function openTop() {
if (!top) return;
if (top.kind === "artist") openArtist(top.data.name, top.data);
else if (top.kind === "album") openAlbum(top.data);
else if (top.data.album_id) push("catalogAlbum", { id: top.data.album_id, artist: top.data.artist, title: top.data.album }, screenEl);
}
function playTop() {
if (!top) return;
if (top.kind === "artist") {
const t = allTracks.find((x) => norm(x.artist) === norm(top.data.name)) ?? allTracks[0];
if (t) playSong(t);
} else if (top.kind === "album") {
streamCatalogAlbum(top.data).catch((e) => { player.error = String(e); });
} else {
playSong(top.data);
}
}
function playSong(t) {
streamSong(t.artist, t.title, {
trackId: t.id, albumId: t.album_id ?? null, album: t.album || null,
cover: t.cover ?? null, expectSecs: t.duration_secs ?? null,
}).catch((e) => { player.error = String(e); });
}
const current = (t) => player.track?.id === "cat:" + t.id;
/* ── the library ───────────────────────────────────────────────── */
const lnorm = (s) => String(s || "").toLowerCase();
const words = $derived(lnorm(q).split(/\s+/).filter(Boolean));
const albumHits = $derived(
words.length
? lib.albums.filter((a) => { const hay = lnorm(a.title + " " + a.artist); return words.every((w) => hay.includes(w)); }).slice(0, 12)
: [],
);
const artistHits = $derived(
words.length
? artists().filter((ar) => { const hay = lnorm(ar.name); return words.every((w) => hay.includes(w)); }).slice(0, 6)
: [],
);
let tracks = $state([]);
let searching = $state(false);
let timer = null;
let seq = 0;
$effect(() => {
const qq = query;
clearTimeout(timer);
if (qq.length < 2) { tracks = []; searching = false; return; }
searching = true;
const my = ++seq;
timer = setTimeout(() => {
api.libraryTrackSearch(qq).then(
(rows) => { if (my === seq) { tracks = (rows || []).slice(0, 20); searching = false; } },
() => { if (my === seq) { tracks = []; searching = false; } },
);
}, 160);
});
const libAny = $derived(artistHits.length || albumHits.length || tracks.length);
const playHit = playIndexRow;
function moreHit(row) {
const album = albumOfRow(row) || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null };
openHalf("track", { album, track: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null } });
}
/* ── the raw network, on request ───────────────────────────────── */
const online = $derived(slsk.connection === "online");
const netHere = $derived(online && net.query === query && (net.searching || net.groups.length > 0));
let expanded = $state(new Set());
function askNetwork() {
if (!online || query.length < 2) return;
netSearch(query).catch(() => {});
inputEl?.blur();
}
function streamEntries(g, from) {
const meta = folderMeta(g.folder);
const artKey = "stream:" + g.username + "|" + g.folder;
const album = { id: null, title: meta.album || g.folderName, artist: meta.artist || g.username, year: meta.year || null, art_path: artKey };
return g.files.slice(from).map((f) => ({
t: { id: "stream:" + g.username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || g.username, path: f.path, duration_secs: f.duration ?? null, stream: { username: g.username, size: f.size } },
album,
}));
}
function playPeer(g, i) {
const es = streamEntries(g, i);
if (es.length) playEntries(es, 0, g.folderName).catch((e) => { player.error = String(e); });
}
function expand(key) { expanded = new Set([...expanded, key]); }
function enter(e) {
if (e.key !== "Enter") return;
e.preventDefault();
inputEl?.blur();
}
</script>
<div class="phead"><div class="row"><h1>Search</h1></div></div>
<div class="sfield">
<Icon name="search" size={18} />
<input bind:this={inputEl} value={q} oninput={(e) => setQ(e.currentTarget.value)} type="search" placeholder="Artists, records, songs" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" aria-label="Search" onkeydown={enter} />
{#if q}
<button class="iconbtn sm" onclick={() => { setQ(""); inputEl?.focus(); }} aria-label="Clear"><Icon name="x" size={18} /></button>
{/if}
</div>
{#if !asking}
<div class="stub">
<b>The catalog, your library{online ? " and the network" : ""}.</b>
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if}
</div>
{:else}
{#if catErr}<div class="sec-note">{catErr}</div>{/if}
{#if catBusy && !cat}<div class="sec-note">Asking the catalog…</div>{/if}
{#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.</div>{/if}
{#if top}
<div class="top" class:round={top.kind === "artist"}>
<div class="top-bg" style:background-image={topArt ? `url("${topArt}")` : null}></div>
<div class="top-in">
<button class="top-art" onclick={openTop} aria-label={top.kind === "artist" ? "Open artist" : "Open record"}>
{#if topArt}<img src={topArt} alt="" />{:else}<span class="ph"><Icon name={top.kind === "artist" ? "users" : "disc"} size={30} /></span>{/if}
</button>
<div class="top-text">
<div class="k">Top result · {top.kind === "artist" ? "Artist" : top.kind === "album" ? "Record" : "Song"}</div>
<div class="top-name">{top.kind === "artist" ? top.data.name : top.data.title}</div>
{#if top.kind === "artist"}
<div class="m">{[fans(top.data.fans), topReleases.length ? topReleases.length + " release" + (topReleases.length === 1 ? "" : "s") : "", profile?.listeners ? fans(profile.listeners).replace("fans", "listeners") : ""].filter(Boolean).join(" · ")}</div>
{#if heroTags.length}<div class="tags">{#each heroTags as t (t)}<span class="chip quiet">{t}</span>{/each}</div>{/if}
{:else if top.kind === "album"}
<div class="m">
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
{[yearOf(top.data.release_date), top.data.track_count ? `${top.data.track_count} tracks` : ""].filter(Boolean).map((x) => " · " + x).join("")}
</div>
{:else}
<div class="m">
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
{top.data.album ? " · " + top.data.album : ""}{top.data.duration_secs ? " · " + mmss(top.data.duration_secs) : ""}
</div>
{/if}
<div class="top-acts">
<button class="btn btn-fill" onclick={playTop}><Icon name="play" size={14} />{top.kind === "song" && current(top.data) && resolver.active ? "Finding…" : "Play"}</button>
<button class="btn btn-ghost" onclick={openTop}>{top.kind === "artist" ? "Open artist" : top.kind === "album" ? "Open record" : "Go to record"}</button>
</div>
</div>
</div>
</div>
{#if latestRelease}
<button class="row" onclick={() => openAlbum(latestRelease)}>
<span class="art">{#if latestRelease.cover}<img src={latestRelease.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b"><span class="t">{latestRelease.title}</span><span class="m">Latest release{yearOf(latestRelease.release_date) ? " · " + yearOf(latestRelease.release_date) : ""}{latestRelease.record_type && latestRelease.record_type !== "album" ? " · " + latestRelease.record_type.toUpperCase() : ""}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
{/if}
{#if catArtists.length}
<div class="sec"><h2>Artists</h2></div>
<div class="rail">
{#each catArtists as ar (ar.id)}
<button class="acard cat-artist" onclick={() => openArtist(ar.name, ar)}>
<span class="por">{#if picture(ar.picture)}<img src={picture(ar.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{ar.name}</span>
<span class="kd">{fans(ar.fans) || "Artist"}</span>
</button>
{/each}
</div>
{/if}
{#if catAlbums.length}
<div class="sec"><h2>Records</h2></div>
<div class="rail">
{#each catAlbums as a (a.id)}
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} />
{/each}
</div>
{/if}
{#if catTracks.length}
<div class="sec"><h2>Songs</h2></div>
{#each catTracks as t (t.id)}
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)}>
<span class="art">{#if t.cover}<img src={t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b"><span class="t">{t.title}</span><span class="m">{t.artist}{t.album ? " · " + t.album : ""}</span></span>
<span class="r">{current(t) && resolver.active ? "finding…" : mmss(t.duration_secs)}</span>
</button>
{/each}
{/if}
{#if libAny}
<div class="sec"><h2>In your library{#if searching} <em>· searching…</em>{/if}</h2></div>
{#each artistHits as ar (ar.name)}
<button class="row" onclick={() => openArtist(ar.name)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b"><span class="t">{ar.name}</span><span class="m">{ar.albums} record{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{#if albumHits.length}
<div class="rail">
{#each albumHits as a (a.id)}
<Release title={a.title} sub={a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{/if}
{#each tracks as row (row.path)}
<button class="trk" class:playing={player.track?.path === row.path} onclick={() => playHit(row)} oncontextmenu={(e) => { e.preventDefault(); moreHit(row); }}>
<span class="n"><Icon name="play" size={12} /></span>
<span class="b"><span class="t">{row.title}</span><span class="m">{row.artist} · {row.album}</span></span>
<span class="d">{mmss(row.duration_secs)}</span>
<span class="r" role="button" tabindex="0" aria-label="More" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); moreHit(row); }}><Icon name="more" size={17} /></span>
</button>
{/each}
{/if}
{#if online}
{#if !netHere}
<div style="padding: 18px var(--pad) 0">
<button class="btn btn-ghost wide net-ask" onclick={askNetwork}><Icon name="globe" size={16} />Search the network for “{query}”</button>
</div>
{:else}
<div class="sec">
<h2>The network <em>· {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}</em></h2>
{#if !net.searching}<button class="all net-ask" onclick={askNetwork}>Again<Icon name="refresh" size={14} /></button>{/if}
</div>
{#if net.error}<div class="sec-note">{net.error}</div>{/if}
{#if net.searching && !net.groups.length}
<div class="sec-note">Asking the network as {slsk.username}…</div>
{/if}
{#each net.groups.slice(0, 40) as g (g.key)}
<div class="peer">
<div class="peer-head">
<span class="peer-who">
<Identicon seed={g.username} size={30} />
<span><b>{g.username}</b><i>{g.slot ? "slot free" : "queue " + g.queue} · {speedLabel(g.speed)}</i></span>
</span>
<span class="dot" class:warn={!g.slot}></span>
</div>
<button class="peer-folder" onclick={() => playPeer(g, 0)}>
<span class="peer-path">{g.folderName}</span>
<span class="peer-meta">{groupFormat(g)} · {g.files.length} file{g.files.length === 1 ? "" : "s"} · {fmtBytes(g.size)}</span>
<span class="peer-get"><Icon name="play" size={14} />Play</span>
</button>
<div class="peer-files">
{#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)}
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)}>
<span>{i + 1}</span>
<span class="pf-t">{titleFromName(f.name)}</span>
<span class="pf-m">{f.bitrate ? f.bitrate + " kbps" : (f.extension || "").toUpperCase()}{f.duration ? " · " + mmss(f.duration) : ""}</span>
</button>
{/each}
{#if g.files.length > 3 && !expanded.has(g.key)}
<button class="peer-more" onclick={() => expand(g.key)}>+ {g.files.length - 3} more</button>
{/if}
</div>
</div>
{/each}
{/if}
{/if}
{/if}
<div class="endpad"></div>