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:
1 parent
6e39dc43f3
commit
26928b706d
21 files changed
+1294
-92
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user