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

+226 -5
View File
@@ -5,15 +5,21 @@
are key/value rows, not buttons. */
import Icon from "../components/Icon.svelte";
import Cover from "./Cover.svelte";
import Identicon from "../components/Identicon.svelte";
import { speedLabel, groupEntries } from "./net.svelte.js";
import * as api from "../lib/api.js";
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
@@ -29,6 +35,7 @@
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
if (t.n === "catalogAlbum") return "catalogAlbum-more";
return "home-more";
});
@@ -51,6 +58,15 @@
: null,
);
const trackArg = $derived(kind === "track" ? shown?.arg : null);
/* the catalog record page's sheet reads the page the store holds */
const catPage = $derived.by(() => {
if (kind === "catalogAlbum-more") return catalog.albums[top().c.id]?.page ?? null;
if (kind === "catalogAlbum" && shown?.arg?.album) {
const a = shown.arg.album;
return { album: { ...a, artist: a.artist || a.artistName || "" }, tracks: catalog.albums[a.id]?.page?.tracks ?? [] };
}
return null;
});
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
@@ -123,6 +139,80 @@
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* find a local match — the desktop's repair panel, on a sheet: the
library searched by the row's name, a pick relinks the row (an
unknown placeholder adopts the track's identity; the original line
survives in source_line) */
let matchQ = $state("");
let matchRows = $state([]);
let matchBusy = $state(false);
let matchGen = 0;
$effect(() => {
const h = nav.half;
if (h?.kind !== "plmatch" || !h.arg?.item) return;
matchQ = [h.arg.item.artist, h.arg.item.title].filter(Boolean).join(" ");
matchRows = [];
queueMicrotask(() => void runMatch());
});
async function runMatch() {
const g = ++matchGen;
const q = matchQ.trim();
if (!q) { matchRows = []; matchBusy = false; return; }
matchBusy = true;
try {
const rows = await api.libraryTrackSearch(q);
if (g === matchGen) matchRows = rows || [];
} catch {
if (g === matchGen) matchRows = [];
} finally {
if (g === matchGen) matchBusy = false;
}
}
async function applyMatch(p, item, c) {
const unknown = item.kind === "unresolved" && (!item.artist || item.title.trim().toLowerCase() === "id");
closeHalf();
try {
await api.playlistRelinkItem(p.id, item.id, {
path: c.path, artPath: c.art_path, durationSecs: c.duration_secs,
title: unknown ? c.title : null, artist: unknown ? c.artist : null, album: unknown ? c.album : null,
});
await refreshPlaylists();
note(`Linked to “${c.title}”`);
} catch (e) { oops(e); }
}
/* the desktop's Save menu: a streamed track filed into the library —
at once when its cache is byte-complete, else through the download
pipeline — and the whole streamed record the queue holds */
const streamMates = (track, album) => player.queue.filter((e) => e.t?.stream && e.t.path && e.album?.title === album?.title && e.album?.artist === album?.artist);
async function saveStreams(entries) {
const srcs = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path, size: e.t.stream.size }));
if (!srcs.length) return;
try {
const [organized, queued] = await api.saveToLibrary(srcs);
note(organized && !queued ? (organized === 1 ? "Saved to the library" : organized + " tracks saved to the library")
: queued ? queued + " downloading into the library" + (organized ? " · " + organized + " saved" : "")
: "Nothing to save");
} catch (e) { oops(e); }
}
/* a peer's sheet (a raw result's folder, or the browse screen's user) */
function playGroup(g) {
const es = groupEntries(g, 0);
if (es.length) playEntries(es, 0, g.folderName).catch(oops);
}
async function saveGroup(g) {
try {
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size);
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
} catch (e) { oops(e); }
}
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
async function refetchCover(album) {
try {
await api.refreshAlbumArt(album.id);
if (album.art_path) { delete art[album.art_path]; wantArt(album.art_path); }
note("Cover re-fetched for “" + album.title + "”");
} catch (e) { oops(e); }
}
/* the prompt's field, seeded when the prompt opens */
let promptVal = $state("");
$effect(() => {
@@ -188,6 +278,14 @@
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
</div>
<div class="sheet-rows">
{#if trackArg.queueIndex != null}
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
{#if trackArg.queueIndex > player.qi + 1}
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))}
{/if}
{@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))}
<div class="sheet-hair"></div>
{/if}
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
@@ -200,6 +298,13 @@
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{:else}
{#if track.stream}
{@render row("download", "Save to library", "this track, from " + track.stream.username, act(() => saveStreams([{ t: track, album }])))}
{#if streamMates(track, album).length > 1}
{@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))}
{/if}
<div class="sheet-hair"></div>
{/if}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
@@ -235,6 +340,10 @@
{/if}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
{#if album.id != null}
<div class="sheet-hair"></div>
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))}
{/if}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
@@ -293,6 +402,9 @@
{:else if item.missing}
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
{/if}
{#if item.kind !== "local" || item.missing}
{@render row("link", "Find a local match", "a track in your library takes this row's place", () => openHalf("plmatch", { playlist: p, items: rows, item }))}
{/if}
{#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if}
@@ -343,6 +455,115 @@
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
{:else if kind === "plmatch" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const item = shown.arg.item}
<div class="sheet-title">Find a local match <span class="t-sub">· {item.title}</span></div>
<div class="sfield plmatch">
<Icon name="search" size={18} />
<input value={matchQ} oninput={(e) => { matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" />
</div>
<div class="sheet-rows">
{#if matchBusy && !matchRows.length}
<div class="sec-note" style="padding-bottom: 12px">Searching…</div>
{:else if !matchRows.length}
<div class="sec-note" style="padding-bottom: 12px">No library tracks match. A named row still streams from the network when you tap it.</div>
{:else}
{#each matchRows.slice(0, 30) as c (c.path)}
<button class="row" onclick={() => applyMatch(p, item, c)}>
<span class="art"><Cover path={c.art_path} size={16} /></span>
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist} — {c.album}</span></span>
<span class="r"><span class="n">{mmss(c.duration_secs)}</span></span>
</button>
{/each}
{/if}
</div>
{:else if kind === "peer" && shown?.arg?.username}
{@const u = shown.arg.username}
{@const g = shown.arg.group || null}
{@const buddy = slsk.buddies.includes(u)}
<div class="sheet-head">
<span class="art idc"><Identicon seed={u} size={44} /></span>
<span class="b"><span class="t">{u}</span><span class="m">{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"}</span></span>
</div>
<div class="sheet-rows">
{#if g}
{@render row("play", "Play this folder", g.files.length + " file" + (g.files.length === 1 ? "" : "s") + ", streamed as a record", act(() => playGroup(g)))}
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
<div class="sheet-hair"></div>
{/if}
{#if !(top().n === "browse" && top().c.username === u)}
{@render row("folder-open", "Browse their files", "everything they share", act(() => push("browse", { username: u })))}
{/if}
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
{@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
</div>
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
{@const al = catPage.album}
{@const tracks = catPage.tracks || []}
<div class="sheet-head">
<span class="art">{#if al.cover}<img src={al.cover} alt="" />{:else}<Icon name="disc" size={22} />{/if}</span>
<span class="b"><span class="t">{al.title}</span><span class="m">{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play", "streams the record from one folder", act(() => streamCatalogAlbum(al, 0).catch(oops)))}
{@render row("download", "Save to library", "the best folder by your download rules", act(() => saveCatalogAlbum(al).then((r) => note(r ? `Saving ${r.files} from ${r.username}` : "No sources found")).catch(oops)))}
{#if tracks.length}
{@render row("playlist", "Add to playlist", tracks.length + " tracks, as streams", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t: { id: "cat:" + t.id, title: t.title, artist: t.artist || al.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: al.id, cover: al.cover ?? null } }, album: { id: null, catalogId: al.id, title: al.title, artist: al.artist, art_path: al.cover ?? null } })) }))}
{/if}
<div class="sheet-hair"></div>
{#if !(top().n === "catalogAlbum" && top().c.id === al.id)}
{@render row("disc", "Go to record", al.title, act(() => push("catalogAlbum", { id: al.id, artist: al.artist, title: al.title })))}
{/if}
{@render row("users", "Go to artist", al.artist, act(() => push("artist", { name: al.artist })))}
</div>
{:else if kind === "peerfile" && shown?.arg?.group}
{@const g = shown.arg.group}
{@const i = shown.arg.index || 0}
{@const f = g.files[i]}
<div class="sheet-head">
<span class="art idc"><Identicon seed={g.username} size={44} /></span>
<span class="b"><span class="t">{f ? titleFromName(f.name) : "—"}</span><span class="m">{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""}</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play from here", "the rest of the folder follows", act(() => { const es = groupEntries(g, i); if (es.length) playEntries(es, 0, g.folderName).catch(oops); }))}
{#if f}
{@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops)))}
{/if}
<div class="sheet-hair"></div>
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
</div>
{:else if kind === "lyrics"}
<div class="sheet-title">Lyrics <span class="t-sub">· {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}</span></div>
{#if lyr.candidates}
<div class="sheet-sec">Choose the right lyrics</div>
<div class="sheet-rows">
{#if lyr.searching}
<div class="sec-note" style="padding-bottom: 12px">Searching LRCLIB…</div>
{:else if !lyr.candidates.length}
<div class="sec-note" style="padding-bottom: 12px">Nothing found for this track's name.</div>
{:else}
{#each lyr.candidates as c (c.id)}
<button class="row" onclick={act(() => { void chooseCandidate(c.id); })}>
<span class="lead"><Icon name={c.synced ? "lyrics" : "file-text"} size={20} /></span>
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist}{c.album ? " — " + c.album : ""}{c.synced ? " · synced" : " · plain"}{c.instrumental ? " · instrumental" : ""}</span></span>
<span class="r"><span class="n">{c.durationSecs ? mmss(c.durationSecs) : ""}</span></span>
</button>
{/each}
{/if}
<div style="padding: 8px var(--pad) 14px"><button class="btn btn-ghost wide" onclick={closeCandidates}>Keep what is showing</button></div>
</div>
{:else}
<div class="sheet-rows">
{#if lyr.lines.length}
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
<div class="sheet-hair"></div>
{/if}
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
</div>
{/if}
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
@@ -388,7 +609,7 @@
<div class="sheet-rows">
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
{@const buddy = slsk.buddies.includes(u.username)}
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
</button>
+6
View File
@@ -15,6 +15,7 @@
import Cover from "./Cover.svelte";
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Viewer from "./Viewer.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
import Library from "./screens/Library.svelte";
@@ -23,6 +24,7 @@
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
import Browse from "./screens/Browse.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
@@ -68,6 +70,7 @@
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "browse": return { title: e.c.username, kicker: "Their files" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
@@ -224,6 +227,8 @@
<Playlist id={entry.c.id} />
{:else if entry.n === "import"}
<Import />
{:else if entry.n === "browse"}
<Browse username={entry.c.username} />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
@@ -285,6 +290,7 @@
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} role="presentation"></div>
<HalfSheet />
<Sheet />
<Viewer />
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
+12 -29
View File
@@ -16,7 +16,8 @@
import { art } from "./art.svelte.js";
import { albumsBy } from "./lib.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
import { nav, closeSheet, morphSource, openHalf, push } from "./nav.svelte.js";
import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import { mmss, drag } from "./util.js";
let frameEl = $state(null);
@@ -106,33 +107,12 @@
if (player.track) openHalf("track", { album: player.album, track: player.track });
}
/* ── lyrics: the app's own pipeline, per track ───────────────────── */
let lyr = $state({ key: null, status: "none", lines: [], plain: "", offset: 0 });
/* ── lyrics: the store (lyrics.svelte.js), asked for the playing track
whenever the face is up ───────────────────────────────────────── */
$effect(() => {
if (face !== "lyrics" || !player.track) return;
const t = player.track;
const key = t.path;
if (lyr.key === key) return;
lyr = { key, status: "loading", lines: [], plain: "", offset: 0 };
api.lyricsGet({
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
}).then(
(p) => {
if (lyr.key !== key) return;
lyr = {
key,
status: p?.status || "none",
lines: p?.synced || [],
plain: p?.plain || "",
offset: (p?.offset_ms || 0) + (p?.auto_offset_ms || 0),
};
},
() => { if (lyr.key === key) lyr = { key, status: "none", lines: [], plain: "", offset: 0 }; },
);
void player.track.path;
void loadLyrics();
});
const nowMs = $derived(player.position * 1000);
const activeIdx = $derived.by(() => {
@@ -171,7 +151,7 @@
<button class="iconbtn" onclick={more} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="np-art" class:small>
<div class="np-art" class:small onclick={() => openViewer(player.album?.art_path)} role="presentation">
<div class="np-frame" bind:this={frameEl}><Cover path={player.album.art_path} eager size={64} /></div>
</div>
@@ -188,7 +168,7 @@
{:else}
<div class="np-face" bind:this={faceEl}>
{#if face === "lyrics"}
<div class="lyr">
<div class="lyr" oncontextmenu={(e) => { e.preventDefault(); openHalf("lyrics"); }} role="presentation">
{#if lyr.status === "loading"}
<div class="lyr-note">Looking for the words…</div>
{:else if lyr.lines.length}
@@ -205,11 +185,14 @@
</button>
{/if}
{/each}
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Out of time, or wrong? ⋯</button>
{:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Wrong lyrics? ⋯</button>
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Not so? ⋯</button>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
{/if}
@@ -225,7 +208,7 @@
{#if e.album?.title !== prevAlbum}
<div class="q-break">{e.album?.artist} · {e.album?.title}</div>
{/if}
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t }); }}>
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t, queueIndex: player.qi + 1 + k }); }}>
<span class="art"><Cover path={e.album?.art_path} size={18} /></span>
<span class="b"><span class="t">{e.t.title}</span><span class="m">{e.t.artist || e.album?.artist}</span></span>
<span class="r">{mmss(e.t.duration_secs)}</span>
+75
View File
@@ -0,0 +1,75 @@
<script>
/* The artwork viewer — the desktop's lightbox: the cover alone, as large
as the screen allows, at the full resolution of the canonical cover
for a library record (the thumbnail shows first, the full one lands
over it). Two fingers zoom and pan; one finger pans a zoomed cover;
a tap on the unzoomed cover, or the back gesture, closes it. */
import { nav, closeViewer } from "./nav.svelte.js";
import { art } from "./art.svelte.js";
import * as api from "../lib/api.js";
const key = $derived(nav.viewerKey);
let full = $state("");
$effect(() => {
const k = key;
full = "";
scale = 1; tx = 0; ty = 0;
if (!k) return;
if (/^https?:/i.test(k)) { full = k; return; }
if (/^stream:/.test(k)) return; /* a stream's tag art: the thumbnail is all there is */
let live = true;
api.albumArt(k, 2000).then((u) => { if (live && u) full = u; }, () => { /* the thumbnail stays */ });
return () => { live = false; };
});
const src = $derived(full || (key ? art[key] : "") || "");
/* pinch to zoom, pan while zoomed — pointer events, both fingers tracked */
let scale = $state(1);
let tx = $state(0);
let ty = $state(0);
const pts = new Map();
let pinch = null; /* { d, s, cx, cy, tx, ty } at the pinch's start */
let pan = null; /* { x, y, tx, ty } at a one-finger pan's start */
let moved = false;
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
const center = () => { const [a, b] = [...pts.values()]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
function down(e) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
e.currentTarget.setPointerCapture(e.pointerId);
moved = false;
if (pts.size === 2) { const c = center(); pinch = { d: dist(), s: scale, cx: c.x, cy: c.y, tx, ty }; pan = null; }
else if (pts.size === 1 && scale > 1) pan = { x: e.clientX, y: e.clientY, tx, ty };
}
function move(e) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && pinch) {
moved = true;
scale = Math.min(6, Math.max(1, pinch.s * (dist() / pinch.d)));
const c = center();
tx = pinch.tx + (c.x - pinch.cx);
ty = pinch.ty + (c.y - pinch.cy);
} else if (pts.size === 1 && pan) {
const dx = e.clientX - pan.x;
const dy = e.clientY - pan.y;
if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
tx = pan.tx + dx;
ty = pan.ty + dy;
}
}
function up(e) {
pts.delete(e.pointerId);
if (pts.size < 2) pinch = null;
if (pts.size === 0) pan = null;
if (scale <= 1.04) { scale = 1; tx = 0; ty = 0; }
}
function tap() {
if (!moved && scale === 1) closeViewer();
}
</script>
{#if nav.viewer}
<div class="viewer" onclick={tap} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} role="presentation">
{#if src}<img src={src} alt="" draggable="false" style:transform={scale === 1 ? "" : `translate(${tx}px, ${ty}px) scale(${scale})`} />{/if}
</div>
{/if}
+107
View File
@@ -0,0 +1,107 @@
/* Lyrics on the phone — the app's own pipeline, per track, with the
desktop's tools: the offset nudged half a second at a time and kept for
the track, another LRCLIB candidate chosen when the words are wrong, the
lookup forgotten and done again. One store, read by the player sheet's
lyrics face and by the lyrics sheet. */
import * as api from "../lib/api.js";
import { player } from "./player.svelte.js";
export const lyr = $state({
key: null, // the track's path — the identity the words belong to
status: "none", // loading | synced | plain | instrumental | none
source: "",
lines: [], // [{ time_ms, text, words? }]
plain: "",
offset: 0, // the user's offset plus the automatic correction, ms
userOffset: 0,
autoOffset: 0,
candidates: null, // LRCLIB candidates while choosing, else null
searching: false,
});
let gen = 0;
export function identity() {
const t = player.track;
if (!t) return null;
return {
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
key: t.path,
};
}
function apply(p) {
lyr.status = p?.status || "none";
lyr.source = p?.source || "";
lyr.lines = p?.synced || [];
lyr.plain = p?.plain || "";
lyr.userOffset = p?.offset_ms || 0;
lyr.autoOffset = p?.auto_offset_ms || 0;
lyr.offset = lyr.userOffset + lyr.autoOffset;
}
/** The words for the playing track; `force` asks the network again. */
export async function loadLyrics(force = false) {
const id = identity();
if (!id) return;
if (!force && lyr.key === id.key) return;
const g = ++gen;
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, candidates: null, searching: false });
try {
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null });
if (g !== gen) return;
apply(p);
} catch {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
}
}
/* the desktop's nudge: half a second at a time, kept for the track */
let persist = 0;
export function nudge(deltaMs) {
const id = identity();
if (!id || !lyr.lines.length) return;
lyr.userOffset = Math.max(-30_000, Math.min(30_000, lyr.userOffset + deltaMs));
lyr.offset = lyr.userOffset + lyr.autoOffset;
clearTimeout(persist);
const value = lyr.userOffset;
persist = setTimeout(() => { api.lyricsSetOffset(id.title, id.artist, id.durationSecs, value).catch(() => {}); }, 500);
}
/** Search LRCLIB by this track's name; the candidates wait on the store. */
export async function searchCandidates() {
const id = identity();
if (!id) return;
const g = gen;
lyr.searching = true;
lyr.candidates = [];
try {
const found = await api.lyricsSearch(id.title, id.artist);
if (g === gen) lyr.candidates = found || [];
} catch {
if (g === gen) lyr.candidates = [];
} finally {
if (g === gen) lyr.searching = false;
}
}
export function closeCandidates() { lyr.candidates = null; }
/** Pin a picked candidate as this track's lyrics. */
export async function chooseCandidate(candidateId) {
const id = identity();
if (!id) return;
const g = gen;
try {
const p = await api.lyricsSelect(id.title, id.artist, id.durationSecs, candidateId);
if (g !== gen) return;
apply(p);
lyr.candidates = null;
} catch { /* keep what was showing */ }
}
/** Forget the words and look again. */
export async function forgetLyrics() {
const id = identity();
if (!id) return;
try { await api.lyricsClear(id.title, id.artist, id.durationSecs); } catch { /* nothing kept */ }
await loadLyrics(true);
}
+19 -3
View File
@@ -34,6 +34,9 @@ export const nav = $state({
face: "art",
/* the half sheet: { kind, arg } */
half: null,
/* the artwork viewer, and the art key it shows */
viewer: false,
viewerKey: null,
});
/** Where the container transform starts — the mini bar's art, measured the
@@ -54,7 +57,7 @@ export function isRoot() {
}
function backables() {
return (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
return (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
}
@@ -93,7 +96,8 @@ export function bootNav() {
window.addEventListener("popstate", () => {
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
depth = Math.max(0, depth - 1);
if (nav.sheet) nav.sheet = false;
if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
@@ -145,7 +149,8 @@ export function push(n, c = {}, screenEl = null) {
/** The in-app back chevron. Same order as the system gesture. */
export function back() {
if (nav.sheet) nav.sheet = false;
if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
@@ -184,6 +189,17 @@ export function closeSheet() {
nav.sheet = false;
sync();
}
export function openViewer(key) {
if (!key) return;
nav.viewerKey = key;
nav.viewer = true;
sync();
}
export function closeViewer() {
if (!nav.viewer) return;
nav.viewer = false;
sync();
}
export function openHalf(kind, arg = null) {
nav.half = { kind, arg };
sync();
+12
View File
@@ -6,6 +6,7 @@
sees a sorted snapshot every quarter second. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { titleFromName, folderMeta } from "./util.js";
export const AUDIO = new Set(["flac", "mp3", "mp2", "m4a", "m4b", "aac", "ogg", "oga", "wav", "aiff", "aif", "opus", "wma", "ape", "wv", "mpc", "tta", "dsf", "dff", "alac"]);
export const LOSSLESS = new Set(["flac", "wav", "aiff", "aif", "ape", "wv", "alac", "dsf", "dff", "tta"]);
@@ -146,3 +147,14 @@ export function groupFormat(g) {
if (LOSSLESS.has(g.topExt)) return ext + (g.maxDepth ? " " + g.maxDepth + "-bit" : "");
return ext + (g.maxBitrate ? " " + g.maxBitrate : "");
}
/** A group's files as the player's entries: the stream engine plays each
* as it arrives; the folder's name is read for artist / record / year. */
export function groupEntries(g, from = 0) {
const meta = folderMeta(g.folder);
const album = { id: null, title: meta.album || g.folderName, artist: meta.artist || g.username, year: meta.year || null, art_path: "stream:" + g.username + "|" + g.folder };
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,
}));
}
+19
View File
@@ -401,6 +401,25 @@ export function playQueueAt(i) {
player.qi = i;
return startEntry(player.queue[i]);
}
/** Drop an upcoming row from the queue (the shuffle's original order too). */
export function removeFromQueue(i) {
if (i <= player.qi || i >= player.queue.length) return;
const [gone] = player.queue.splice(i, 1);
if (original && gone) {
const k = original.indexOf(gone);
if (k >= 0) original.splice(k, 1);
}
armPreload();
writeSession();
}
/** Pull an upcoming row up to right after the current track. */
export function moveToNext(i) {
if (i <= player.qi + 1 || i >= player.queue.length) return;
const [row] = player.queue.splice(i, 1);
player.queue.splice(player.qi + 1, 0, row);
armPreload();
writeSession();
}
/** After the current track. */
export function playNext(entries) {
if (player.qi < 0) return playEntries(entries, 0, player.from);
+2 -2
View File
@@ -7,7 +7,7 @@
import Release from "../Release.svelte";
import { albumById, albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { push, openHalf, openViewer } from "../nav.svelte.js";
import { mmss, longDur, albumFmt } from "../util.js";
let { id, screenEl = null } = $props();
@@ -40,7 +40,7 @@
{#if album}
<div class="rec-head">
<div class="rec-cover"><Cover path={album.art_path} eager size={48} /></div>
<div class="rec-cover" onclick={() => openViewer(album.art_path)} role="presentation"><Cover path={album.art_path} eager size={48} /></div>
<div class="rec-meta">
<div class="rec-title">{album.title}</div>
<button class="rec-artist" onclick={() => push("artist", { name: album.artist }, screenEl)}>{album.artist}</button>
+1
View File
@@ -270,6 +270,7 @@
path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
+155
View File
@@ -0,0 +1,155 @@
<script>
/* A user's shares — the desktop hub's Browse tab: every folder they
share, its files with sizes and formats, the totals. A folder plays
as a record through the stream engine, a file plays from its place,
Save fetches a folder into the library as it is. One request for
the whole list; a big share takes its time. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { player, playEntries } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { username } = $props();
let dirs = $state(null);
let busy = $state(false);
let error = $state("");
let open = $state(new Set());
let gen = 0;
async function load() {
const g = ++gen;
busy = true;
error = "";
dirs = null;
try {
const d = await api.slskBrowseUser(username);
if (g === gen) dirs = (d || []).filter((x) => x.files?.length);
} catch (e) {
if (g === gen) error = String(e);
} finally {
if (g === gen) busy = false;
}
}
$effect(() => { void username; void load(); });
const totals = $derived.by(() => {
let files = 0;
let bytes = 0;
for (const d of dirs || []) {
files += d.files.length;
for (const f of d.files) bytes += f.size || 0;
}
return { folders: dirs?.length || 0, files, bytes };
});
const dirName = (d) => d.name.split(/[\\/]/).filter(Boolean).pop() || d.name;
/* a share can hold thousands of folders: a filter, and a cap that
extends on demand — never the whole list at once */
const CAP = 80;
let q = $state("");
let cap = $state(CAP);
const shown = $derived.by(() => {
const k = q.trim().toLowerCase();
const list = dirs || [];
return k ? list.filter((d) => d.name.toLowerCase().includes(k)) : list;
});
$effect(() => { void q; cap = CAP; });
const audio = (f) => /^(flac|mp3|m4a|aac|ogg|opus|wav|aiff?|alac|wma|ape|wv)$/i.test(f.ext || "");
const dirBytes = (d) => d.files.reduce((n, f) => n + (f.size || 0), 0);
/* the folder as the player's entries: the stream engine plays each file
as it arrives, the folder's name read for artist / record / year */
function entries(d, from = 0) {
const meta = folderMeta(d.name);
const album = { id: null, title: meta.album || dirName(d), artist: meta.artist || username, year: meta.year || null, art_path: "stream:" + username + "|" + d.name };
return d.files.filter(audio).slice(from).map((f) => ({
t: { id: "stream:" + username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || username, path: f.path, duration_secs: f.duration ?? null, stream: { username, size: f.size } },
album,
}));
}
function play(d, f = null) {
const list = d.files.filter(audio);
const es = entries(d, f ? Math.max(0, list.indexOf(f)) : 0);
if (es.length) playEntries(es, 0, dirName(d)).catch((e) => { player.error = String(e); });
}
async function save(d) {
const list = d.files.filter(audio);
if (!list.length) return;
try {
for (const f of list) await api.downloadFile(username, f.path, f.size);
note(`Saving ${list.length} file${list.length === 1 ? "" : "s"} from ${username}`);
} catch (e) { player.error = String(e); }
}
/* a file's sheet: the folder as the sheet's group shape */
function fileSheet(d, f) {
const files = d.files.filter(audio);
openHalf("peerfile", { group: { username, folder: d.name, folderName: dirName(d), files, slot: true, speed: 0, queue: 0 }, index: Math.max(0, files.indexOf(f)) });
}
function toggle(name) {
const s = new Set(open);
if (s.has(name)) s.delete(name); else s.add(name);
open = s;
}
const fmtLine = (f) => (f.bitrate ? f.bitrate + " kbps" : (f.ext || "").toUpperCase()) + (f.duration ? " · " + mmss(f.duration) : "") + " · " + fmtBytes(f.size);
</script>
<div class="brw-head">
<Identicon seed={username} size={44} />
<div class="b">
<div class="t">{username}</div>
<div class="m">{busy ? "Reading their shares…" : error ? "Couldn't read their shares" : dirs ? `${totals.folders} folder${totals.folders === 1 ? "" : "s"} · ${totals.files} file${totals.files === 1 ? "" : "s"} · ${fmtBytes(totals.bytes)}` : ""}</div>
</div>
<button class="iconbtn muted" onclick={() => openHalf("peer", { username })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
{#if busy}
<div class="imp-wait"><span class="ring"></span><b>Reading {username}'s shares</b><span>One request for the whole list; a big share takes a moment.</span></div>
{:else if error}
<div class="stub"><b>Couldn't read their shares.</b>{error}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={load}>Try again</button></div>
{:else if dirs && !dirs.length}
<div class="stub"><b>Nothing shared.</b>{username} shares no files, or keeps the share private.</div>
{:else if dirs}
{#if dirs.length > 8}
<div class="sfield">
<Icon name="search" size={18} />
<input value={q} oninput={(e) => (q = e.currentTarget.value)} type="search" placeholder="Filter {totals.folders} folders" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Filter folders" />
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
</div>
{#if q}<div class="sec-note">{shown.length} of {dirs.length} folders</div>{/if}
{/if}
{#each shown.slice(0, cap) as d (d.name)}
{@const files = d.files.filter(audio)}
<div class="peer brw-dir">
<button class="peer-folder" onclick={() => toggle(d.name)}>
<span class="peer-path">{dirName(d)}</span>
<span class="peer-meta">{d.files.length} file{d.files.length === 1 ? "" : "s"} · {fmtBytes(dirBytes(d))}{files.length && files.length !== d.files.length ? ` · ${files.length} audio` : ""}</span>
<span class="peer-get"><Icon name={open.has(d.name) ? "chevron-up" : "chevron-down"} size={14} />{open.has(d.name) ? "Less" : "Files"}</span>
</button>
{#if open.has(d.name)}
{#if files.length}
<div class="brw-acts">
<button class="btn btn-fill" onclick={() => play(d)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => save(d)}><Icon name="download" size={14} />Save to library</button>
</div>
{/if}
<div class="peer-files">
{#each d.files as f, i (f.path)}
<button class="peer-file" class:playing={player.track?.path === f.path} disabled={!audio(f)} onclick={() => play(d, f)} oncontextmenu={(e) => { e.preventDefault(); fileSheet(d, f); }}>
<span>{i + 1}</span>
<span class="pf-t">{audio(f) ? titleFromName(f.name) : f.name}</span>
<span class="pf-m">{fmtLine(f)}</span>
</button>
{/each}
</div>
{/if}
</div>
{/each}
{#if shown.length > cap}
<div style="padding: 0 var(--pad) 8px"><button class="btn btn-ghost wide brw-more" onclick={() => (cap += 200)}>+ {shown.length - cap} more folders</button></div>
{/if}
{/if}
<div class="endpad"></div>
+11 -2
View File
@@ -27,6 +27,10 @@
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
);
const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
/* the hub's browse-by-name: a typed name opens the peer sheet */
function lookUp() {
openHalf("prompt", { title: "Look up a user", placeholder: "Soulseek username", ok: "Open", onsubmit: (v) => { const u = v.trim(); if (u) openHalf("peer", { username: u }); } });
}
async function signIn() {
if (!user.trim() || !pass) return;
@@ -140,11 +144,16 @@
</button>
{/each}
{:else if tab === "buddies"}
<button class="row" onclick={lookUp}>
<span class="lead"><Icon name="search" size={20} /></span>
<span class="b"><span class="t">Look up a user</span><span class="m">a Soulseek name: browse their files, message them, add them</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if !buddies.length}
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one.</div>
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one, or look one up above.</div>
{/if}
{#each buddies as b (b.u)}
<button class="row" onclick={() => thread(b.u)}>
<button class="row" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
<span class="b">
<span class="t">{b.u}</span>
+2 -2
View File
@@ -219,7 +219,7 @@
<div class="sec"><h2>New from your artists <em>· {lib.follows.length} followed</em></h2></div>
<div class="rail">
{#each feedRest as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={kindLabel(r.record_type) + " · " + r.artistName} url={r.cover} badge={r.upcoming ? inDays(r.release_date) : age(r.release_date)} onclick={() => openRelease(r)} />
<Release title={r.title} sub={kindLabel(r.record_type) + " · " + r.artistName} url={r.cover} badge={r.upcoming ? inDays(r.release_date) : age(r.release_date)} onclick={() => openRelease(r)} onmore={() => { if (!r.mbOnly && r.id != null) openHalf("catalogAlbum", { album: r }); }} />
{/each}
</div>
{/if}
@@ -251,7 +251,7 @@
<div class="sec"><h2>From the back catalogue <em>· albums you may have missed</em></h2></div>
<div class="rail">
{#each backCatalogue as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={r.artistName} url={r.cover} badge={age(r.release_date)} onclick={() => openRelease(r)} />
<Release title={r.title} sub={r.artistName} url={r.cover} badge={age(r.release_date)} onclick={() => openRelease(r)} onmore={() => { if (!r.mbOnly && r.id != null) openHalf("catalogAlbum", { album: r }); }} />
{/each}
</div>
{/if}
+1 -1
View File
@@ -165,7 +165,7 @@
{#if gapOpen[key] && gapRows[key]?.length}
<div class="rail gaps">
{#each gapRows[key] as r (r.id)}
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} />
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} onmore={() => openHalf("catalogAlbum", { album: { ...r, artist: r.artist || ar.name } })} />
{/each}
</div>
{/if}
+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>
+153 -6
View File
@@ -3,8 +3,9 @@
running. Playback, storage and the network settings come next. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { lib, loadLibrary } from "../lib.svelte.js";
import { push } from "../nav.svelte.js";
import { lib, loadLibrary, refreshRecents } from "../lib.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js";
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
@@ -47,6 +48,16 @@
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally { picking = false; }
}
function clearHistory() {
openHalf("confirm", {
title: "Clear the play history?",
body: "Jump back in and the play counts start over. The library and the playlists stay.",
ok: "Clear", danger: true,
onconfirm: async () => {
try { await api.clearPlayHistory(); await refreshRecents(); note("Play history cleared"); } catch (e) { player.error = String(e); }
},
});
}
async function rescan() {
if (scanning) return;
scanning = true;
@@ -61,6 +72,22 @@
const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]];
const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
/* the desktop's Streaming settings: what happens to a streamed track */
const RETENTION = [["1", "1 day"], ["3", "3 days"], ["7", "1 week"], ["14", "2 weeks"], ["30", "1 month"], ["90", "3 months"], ["forever", "Forever — save to library"]];
const CACHE = [["512", "512 MB"], ["1024", "1 GB"], ["2048", "2 GB"], ["5120", "5 GB"], ["10240", "10 GB"], ["20480", "20 GB"], ["0", "Unlimited"]];
const retention = $derived(!cfg ? "30" : cfg.stream_keep_forever ? "forever" : String(cfg.stream_retention_days ?? 30));
const cacheMax = $derived(!cfg ? "2048" : String(cfg.stream_cache_max_mb ?? 2048));
async function setRetention(v) {
if (v === retention) return;
try {
await api.setSettings(v === "forever" ? { streamKeepForever: true } : { streamKeepForever: false, streamRetentionDays: Number(v) });
cfg = await api.getSettings();
} catch (e) { player.error = String(e); }
}
async function setCacheMax(v) {
if (v === cacheMax) return;
try { await api.setSettings({ streamCacheMaxMb: Number(v) }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) });
async function setPref(mode, field, value) {
if (prefsOf(mode)[field] === value) return;
@@ -72,6 +99,43 @@
} catch (e) { player.error = String(e); }
}
/* the desktop's Library & downloads / Sharing / Lyrics / Album artwork
settings, on the phone's components; every save camelCase */
const ART_MODE = [["replace", "Canonical cover"], ["keep", "Uploader's art"]];
const ART_EDGE = [["0", "Original"], ["1500", "1500 px"], ["1000", "1000 px"]];
const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace");
const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500));
let refetching = $state(false);
async function saveCfg(patch) {
try { await api.setSettings(patch); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
const setFlag = (key, v) => saveCfg({ [key]: !!v });
const setNumber = (key, v) => saveCfg({ [key]: v });
const setSlots = (n) => saveCfg({ uploadSlots: Math.max(1, Math.min(10, n)) });
function layoutPrompt() {
openHalf("prompt", {
title: "Album folder format", value: cfg?.library_layout || "", placeholder: "{artist}\\{album} ({year})", ok: "Save",
onsubmit: (v) => { if (v.trim()) void saveCfg({ libraryLayout: v.trim() }); },
});
}
async function addShared() {
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can share — pick one on this phone's storage."; return; }
const have = cfg?.shared_folders ?? [];
if (!have.includes(r.path)) await saveCfg({ sharedFolders: [...have, r.path] });
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
}
}
const removeShared = (folder) => saveCfg({ sharedFolders: (cfg?.shared_folders ?? []).filter((f) => f !== folder) });
async function refetchCovers() {
if (refetching) return;
refetching = true;
try { const n = await api.refreshAllAlbumArt(); note(`${n ?? ""} cover${n === 1 ? "" : "s"} re-fetched`.trim()); }
catch (e) { player.error = String(e); }
finally { refetching = false; }
}
const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
@@ -122,6 +186,10 @@
<span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
</button>
<button class="row" onclick={clearHistory}>
<span class="lead"><Icon name="history" size={20} /></span>
<span class="b"><span class="t">Clear play history</span><span class="m">what Jump back in and the play counts are built from</span></span>
</button>
<button class="row" onclick={() => push("transfers", {})}>
<span class="lead"><Icon name="download" size={20} /></span>
<span class="b"><span class="t">Transfers</span><span class="m">{active().length ? `${active().length} in flight` : Object.keys(transfers.items).length ? "nothing in flight" : "what Save is fetching into the library"}</span></span>
@@ -129,6 +197,23 @@
</button>
</div>
<div class="sheet-sec">Streaming</div>
<div class="sec-note">What happens to a track after you streamed it. Streamed songs stay in a private cache for instant replay — not in your library; Forever adds everything you stream to the library instead. Retention counts from the last play; saved and downloaded music is never touched.</div>
<div class="group prefs" data-mode="streamcache">
<div class="pref">
<div class="pref-l"><span class="t">Keep streamed tracks</span><span class="m">how long the cache holds a track after its last play</span></div>
<div class="chips">
{#each RETENTION as [v, l] (v)}<button class="chip" class:on={retention === v} class:quiet={retention !== v} onclick={() => setRetention(v)}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Cache size limit</span><span class="m">the oldest streamed tracks go first past this size</span></div>
<div class="chips">
{#each CACHE as [v, l] (v)}<button class="chip" class:on={cacheMax === v} class:quiet={cacheMax !== v} onclick={() => setCacheMax(v)}>{l}</button>{/each}
</div>
</div>
</div>
<div class="sheet-sec">Sources</div>
<div class="sec-note">Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.</div>
{#each MODES as [key, label] (key)}
@@ -155,15 +240,77 @@
</div>
{/each}
<div class="sheet-sec">Downloads</div>
<div class="group">
<button class="row" onclick={layoutPrompt}>
<span class="lead"><Icon name="folder-open" size={20} /></span>
<span class="b"><span class="t">Album folder format</span><span class="m mono">{cfg?.library_layout || "{artist}\\{album}"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
<div class="sec-note" style="padding: 0 14px 12px">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder. New downloads are organized into this format; existing files are not moved.</div>
</div>
<div class="sheet-sec">Sharing</div>
<div class="group">
<div class="kv"><span class="k">Your library folder</span><span class="v dim">always shared · {(cfg?.share_files ?? 0).toLocaleString()} files</span></div>
<div class="row slots">
<span class="lead"><Icon name="users" size={20} /></span>
<span class="b"><span class="t">Upload slots</span><span class="m">how many peers can download from you at once</span></span>
<span class="stepper"><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) - 1)} aria-label="Fewer slots"><Icon name="minus" size={16} /></button><span class="n">{cfg?.upload_slots ?? 2}</span><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) + 1)} aria-label="More slots"><Icon name="plus" size={16} /></button></span>
</div>
{#each cfg?.shared_folders ?? [] as folder (folder)}
<div class="row">
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t mono">{folder}</span><span class="m">also shared</span></span>
<button class="iconbtn sm muted" onclick={() => removeShared(folder)} aria-label="Stop sharing"><Icon name="x" size={18} /></button>
</div>
{/each}
<button class="row" onclick={addShared}>
<span class="lead"><Icon name="plus" size={20} /></span>
<span class="b"><span class="t">Add a shared folder</span><span class="m">on this phone's storage; the library folder needs no adding</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
</div>
<div class="sheet-sec">Lyrics</div>
<div class="group">
<button class="row" onclick={() => setFlag("wordTiming", !cfg?.word_timing)}>
<span class="lead"><Icon name="lyrics" size={20} /></span>
<span class="b"><span class="t">Word-by-word fill</span><span class="m">each word fills as it is sung; off keeps the line-by-line sync everywhere</span></span>
<span class="sw" class:on={!!cfg?.word_timing}><span></span></span>
</button>
</div>
<div class="sheet-sec">Album artwork</div>
<div class="group prefs" data-mode="albumart">
<div class="pref">
<div class="pref-l"><span class="t">Art inside downloaded files</span><span class="m">the canonical cover, or whatever the uploader embedded</span></div>
<div class="chips">
{#each ART_MODE as [v, l] (v)}<button class="chip" class:on={artMode === v} class:quiet={artMode !== v} onclick={() => setFlag("replaceEmbeddedArt", v === "replace")}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Embedded cover size</span><span class="m">the longest edge of the copy written into each file; the original is kept in the folder</span></div>
<div class="chips">
{#each ART_EDGE as [v, l] (v)}<button class="chip" class:on={artEdge === v} class:quiet={artEdge !== v} onclick={() => setNumber("embedArtMaxEdge", Number(v))}>{l}</button>{/each}
</div>
</div>
<button class="row" onclick={() => setFlag("writeFolderCover", !(cfg?.write_folder_cover ?? true))}>
<span class="lead"><Icon name="image" size={20} /></span>
<span class="b"><span class="t">Album folder cover</span><span class="m">cover.jpg and folder.jpg beside the files, so other players show the same art</span></span>
<span class="sw" class:on={cfg?.write_folder_cover ?? true}><span></span></span>
</button>
<button class="row" onclick={refetchCovers} disabled={refetching}>
<span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
</button>
</div>
<div class="sheet-sec">This phone</div>
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
</div>
<div class="stub">
<b>The network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components.
</div>
<div class="endpad"></div>
+30
View File
@@ -1335,3 +1335,33 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.bulkline .ring { width: 14px; height: 14px; flex: none; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
.bulkline .bulkx { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; color: var(--text-muted); }
.bulkline .bulkx:active { background: rgba(255, 255, 255, 0.12); }
/* the repair sheet's search field, inside the half sheet */
.halfsheet .sfield.plmatch { margin: 6px var(--pad) 8px; }
/* ── a peer's menu on a raw result, and the Browse screen ─────────────── */
.peer-head .peer-menu { margin: -6px -6px -6px 2px; }
.brw-head { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 10px; }
.brw-head .b { flex: 1; min-width: 0; }
.brw-head .t { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brw-head .m { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
.brw-acts { display: flex; gap: 8px; padding: 2px 12px 8px; }
.brw-acts .btn { flex: 1; height: 36px; font-size: 13px; gap: 6px; }
.peer-file:disabled { opacity: 0.45; }
/* You → Sharing: the upload-slot stepper; a shared folder's path */
.stepper { display: inline-flex; align-items: center; gap: 2px; flex: none; }
.stepper .n { min-width: 24px; text-align: center; font-weight: 800; font-size: 14px; font-variant-numeric: tabular-nums; }
.group .row .t.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 12.5px; font-weight: 600; }
/* Search: the spelling suggestion, inline in the note */
.sec-note .dym { color: var(--accent-hover); font-weight: 750; padding: 0 2px; }
/* ── the artwork viewer: the cover alone, over everything ─────────────── */
.viewer { position: fixed; inset: 0; z-index: 200; background: #000; display: grid; place-items: center; animation: viewer-in 160ms ease both; touch-action: none; overflow: hidden; }
.viewer img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; will-change: transform; transform-origin: center; }
@keyframes viewer-in { from { opacity: 0; } to { opacity: 1; } }
/* the lyrics face's way to its tools */
.lyr-tools { display: block; margin: 18px auto 8px; padding: 7px 14px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.lyr-tools:active { background: rgba(255, 255, 255, 0.14); }