android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest

The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
This commit is contained in:
KeelrLeek committed 2026-09-13 19:56:20 -05:00
1 parent 6e39dc43f3
commit 26928b706d
21 files changed
+1294 -92

No files matched your search

+85 -16
View File
@@ -17,7 +17,7 @@
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 { net, search as netSearch, speedLabel, groupFormat, groupEntries } 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";
@@ -29,6 +29,22 @@
let inputEl = $state(null);
/** the screen remembers its query across the tab switch (nav's entry) */
function setQ(v) { q = v; patch({ q: v }); }
/* recent searches — the desktop's empty page: the last eight queries
that were USED (the field committed with Enter, or the screen left
with a query in it), kept on the device */
const RECENT_KEY = "search.recent.v1";
let recents = $state((() => { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) ?? []; } catch { return []; } })());
function keepRecents() { try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* storage off */ } }
function remember(v) {
const t = String(v || "").trim();
if (t.length < 2) return;
recents = [t, ...recents.filter((x) => x.toLowerCase() !== t.toLowerCase())].slice(0, 8);
keepRecents();
}
function forget(v) { recents = recents.filter((x) => x !== v); keepRecents(); }
let lastQ = "";
$effect(() => { lastQ = q.trim(); });
$effect(() => () => remember(lastQ));
const query = $derived(q.trim());
const asking = $derived(query.length >= 2);
@@ -60,6 +76,52 @@
const catAlbums = $derived(allAlbums.slice(0, 12));
const catTracks = $derived(allTracks.slice(0, 10));
const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
/* a spelling correction, when one is obvious — the desktop's rule: the
only trustworthy vocabulary offline is the names this phone knows
(the library, the searches that worked), a word is corrected only
when a real name is within two edits, nothing is guessed otherwise */
const vocabulary = $derived.by(() => {
const words = new Set();
const add = (text) => { for (const w of String(text ?? "").toLowerCase().split(/[^a-z0-9']+/)) if (w.length >= 3) words.add(w); };
for (const a of lib.albums) { add(a.artist); add(a.title); }
for (const r of recents) add(r);
return [...words];
});
const didYouMean = $derived.by(() => {
if (!catEmpty || catBusy) return null;
const typed = query.split(/\s+/);
if (!typed.length || !vocabulary.length) return null;
let corrected = false;
const fixed = typed.map((word) => {
const lower = word.toLowerCase();
if (lower.length < 3 || vocabulary.includes(lower)) return word;
let best = null;
let bestDist = 3;
for (const c of vocabulary) { const d = editDistance(lower, c, bestDist); if (d < bestDist) { bestDist = d; best = c; } }
if (!best) return word;
corrected = true;
return best;
});
return corrected ? fixed.join(" ") : null;
});
/** Levenshtein, abandoned as soon as every cell exceeds the limit. */
function editDistance(a, b, limit) {
if (Math.abs(a.length - b.length) >= limit) return limit;
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diag = prev[0];
prev[0] = i;
let row = i;
for (let j = 1; j <= b.length; j++) {
const tmp = prev[j];
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
diag = tmp;
row = Math.min(row, prev[j]);
}
if (row >= limit) return limit;
}
return prev[b.length];
}
/* ── the top result: the desktop's rule, verbatim ──────────────────
Each candidate scores as a SHARE OF ITS OWN KIND'S LEADER, never as a
@@ -146,6 +208,14 @@
}).catch((e) => { player.error = String(e); });
}
const current = (t) => player.track?.id === "cat:" + t.id;
/* a long press: the catalog track's sheet — play, save, add to a
playlist, the record, the artist */
function songSheet(t) {
openHalf("track", {
album: { id: null, catalogId: t.album_id ?? null, title: t.album || null, artist: t.artist, art_path: t.cover ?? null },
track: { id: "cat:" + t.id, title: t.title, artist: t.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: t.album_id ?? null, cover: t.cover ?? null } },
});
}
/* ── the library ───────────────────────────────────────────────── */
const lnorm = (s) => String(s || "").toLowerCase();
@@ -193,23 +263,15 @@
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);
const es = groupEntries(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();
remember(q);
inputEl?.blur();
}
</script>
@@ -230,10 +292,16 @@
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>
{#if recents.length}
<div class="sec"><h2>Recent</h2><button class="all" onclick={() => { recents = []; keepRecents(); }}>Clear</button></div>
<div class="chips recent-chips" style="padding-bottom: 12px">
{#each recents as r (r)}<button class="chip quiet" onclick={() => setQ(r)} oncontextmenu={(e) => { e.preventDefault(); forget(r); }}>{r}</button>{/each}
</div>
{/if}
{: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 catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean <button class="dym" onclick={() => setQ(didYouMean)}>{didYouMean}</button>?{/if}</div>{/if}
{#if top}
<div class="top" class:round={top.kind === "artist"}>
@@ -292,7 +360,7 @@
<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)} />
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} onmore={() => openHalf("catalogAlbum", { album: a })} />
{/each}
</div>
{/if}
@@ -300,7 +368,7 @@
{#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)}>
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)} oncontextmenu={(e) => { e.preventDefault(); songSheet(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>
@@ -350,12 +418,13 @@
{/if}
{#each net.groups.slice(0, 40) as g (g.key)}
<div class="peer">
<div class="peer-head">
<div class="peer-head" oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: g.username, group: g }); }} role="presentation">
<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>
<button class="iconbtn sm muted peer-menu" onclick={() => openHalf("peer", { username: g.username, group: g })} aria-label="More"><Icon name="more" size={18} /></button>
</div>
<button class="peer-folder" onclick={() => playPeer(g, 0)}>
<span class="peer-path">{g.folderName}</span>
@@ -364,7 +433,7 @@
</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)}>
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peerfile", { group: g, index: 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>