playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+4
View File
@@ -4,6 +4,8 @@
import PlayerBar from "./components/PlayerBar.svelte";
import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LyricsOverlay from "./components/LyricsOverlay.svelte";
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
import ImportWindow from "./components/ImportWindow.svelte";
import LoginModal from "./components/LoginModal.svelte";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
@@ -126,6 +128,8 @@
<FollowToast />
<ImportToast />
<PreviewOverlay />
<LyricsOverlay />
<LyricsSyncEditor />
<ArtworkViewer />
<ImportWindow />
<LoginModal />
+7
View File
@@ -76,6 +76,13 @@
"brand-bandcamp": '<path d="M22 7h-9.5L2 17h9.5L22 7z"/>',
"brand-soundcloud": '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
"hard-drive": '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1z"/>',
// Drag handle for reorderable rows (six dots, reads at 14px).
grip: '<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>',
export: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
"file-text": '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
link: '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
};
</script>
+136 -14
View File
@@ -16,7 +16,7 @@
import { onDestroy } from "svelte";
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
import { evidence as matchEvidence, normx } from "../lib/import-evidence.js";
import { IDENTIFY_ENGINE } from "../lib/import-title.js";
import { IDENTIFY_ENGINE, majority } from "../lib/import-title.js";
let {
album: a, // derived album from ImportWindow (rel, title, …)
@@ -101,14 +101,79 @@
(async () => {
try {
const ask = albumName ?? a.title;
const r = await api.importIdentify(artist, ask);
// The folder rides along as the second witness: `ask` is the
// tags' claim, and tags carry typos the folder does not
// ("Nonagon Infiniry" on all nine files). Asked after it, and
// deduped away when they agree.
//
// The SHAPE goes too — count, track titles, year, and where the
// files live. A name is only one fact about a record, and it is
// the one most often wrong; the discography pass compares the
// rest when no phrase can find it.
const shape = {
tracks: a.tracks?.length ?? null,
trackTitles: (a.tracks ?? []).map((t) => t.title).filter(Boolean),
year: a.year ?? null,
// `parts` bounds the artwork hash to the record's OWN audio.
// Without it the backend walked the folder recursively, and
// a record sharing a folder with another one hashed the
// neighbour's cover — see own_audio_files in import.rs.
sourceRoot, rel, parts: a.parts ?? [],
};
let r = await api.importIdentify(artist, ask, a.title, shape);
let askedArtist = artist;
// The FOLDER is asked first, and stays the default: it is one
// deliberate decision, where track artist tags carry featured
// guests, compilation contributors and classical performers.
//
// But a folder is sometimes not the artist at all — "Band Of
// Gypsys" holding six files that every one of them tags
// "Jimi Hendrix" — and then the ask is wrong twice over,
// because the try-list strips that "artist" off the front of
// the title and asks the providers for what is left of it.
//
// Only when the first ask came back with NOTHING, or with a
// record the judge calls wrong. A candidate that already
// stands is not this function's business.
const judge = (res, who) => (res?.candidate
? matchEvidence(res.candidate, who, ask, a.tracks.length).verdict
: "none");
if (["none", "bad"].includes(judge(r, artist))) {
const tagged = majority((a.tracks ?? []).map((t) => t.artist));
if (tagged && normx(tagged) !== normx(artist)) {
const alt = await api.importIdentify(tagged, ask, a.title, shape);
// Taken only if it is actually better. A second wrong
// answer is not an improvement on the first.
if (["ok", "warn"].includes(judge(alt, tagged))) {
r = alt;
askedArtist = tagged;
}
}
}
// Stamped with the engine that produced it: a cached "nothing
// found" is only trustworthy while the thing that looked has
// not learned anything new. See IDENTIFY_ENGINE.
found[rel] = { state: "done", result: r, engine: IDENTIFY_ENGINE };
// `askedArtist` rides along so the judge compares against the
// name the provider was ASKED about — the §3.2 rule, which
// would otherwise call this record's own match a wrong one.
// A lookup that FAILED is not an answer about the record. MB's
// 1 req/s limiter makes a 503 routine, and filing one as "done"
// made "Murder of the Universe" a permanent deep cut — the
// record MB holds, scored 100, and gave up on the next ask.
// `persist()` only saves `done`, so an error re-asks; nothing
// else here treats the two alike.
if (r.incomplete && !r.candidate) {
found[rel] = {
state: "error", result: null,
error: "the provider did not answer — this record has not been looked up yet",
};
return;
}
found[rel] = { state: "done", result: { ...r, askedArtist }, engine: IDENTIFY_ENGINE };
if (r.candidate?.cover) {
const rep = await api.importCoverReport(
artist, r.used ?? ask, r.candidate.cover);
askedArtist, r.used ?? ask, r.candidate.cover);
reports[rel] = rep;
}
} catch (e) {
@@ -133,7 +198,10 @@
const rel = a.rel;
if (localArts[rel] !== undefined) return;
localArts[rel] = null;
api.importLocalArt(sourceRoot, rel).then(
// `parts` for the same reason apply takes them: the record's audio is
// its own folder and the parts it named, never a walk of what happens
// to sit underneath.
api.importLocalArt(sourceRoot, rel, a.parts ?? []).then(
(v) => { localArts[rel] = v; },
() => {});
});
@@ -226,7 +294,10 @@
// an exact hit look doubtful: "Mirrored" against
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
// match wore a "!" on its title row.
const ev = $derived(matchEvidence(candidate, artist, albumName ?? a.title, a.tracks.length));
// Against the artist the provider was ASKED about — which is the folder
// unless the tags found the record where the folder could not.
const ev = $derived(matchEvidence(
candidate, idn?.result?.askedArtist ?? artist, albumName ?? a.title, a.tracks.length));
const verdictLine = $derived.by(() => {
const c = candidate;
@@ -285,15 +356,57 @@
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
const resolvedTrack = (n) => choice?.tracks?.[n] ?? "yours";
/** Row order is the RECORD's order; `n` stays the SCAN's.
*
* The scan lists a folder with a plain byte sort (import.rs `paths.sort()`),
* where '0' (0x30) beats '_' (0x5F) — so "10_LONELY WANDERER" precedes
* "1_SEQUENTIAL CIRCUITS" and a record numbered without zero-padding reads
* 10, 11, 12, 13, 1, 2 … 9. Pairing that against a provider tracklist by
* position compares track 10 with track 1 the whole way down, and the
* length differences it reports are the arithmetic of that mispairing.
*
* Ordering is therefore for DISPLAY ONLY. `n` remains each track's index
* in `a.tracks`, because that is the index apply writes by: `track_titles`
* is positional against apply's own `f.sort()` of the same folder
* (import.rs:1238). Reordering `a.tracks` itself would move the review
* without moving apply, and land every chosen title on the wrong file.
*
* A folded release is left in the order the fold built: its tracks are in
* PART order and every part numbers its own tracks from 1, so ordering the
* flattened list by track number would interleave the discs — the trap
* import.rs:1235 and import-fold.js:172 both already guard. */
const trackRows = $derived.by(() => {
const rows = a.tracks.map((t, n) => ({ t, n }));
if ((a.parts?.length ?? 0) > 1) return rows;
// The tags decide when they ALL carry a distinct number: a rarities
// folder where every file claims track 1 orders by nothing, and a
// partial set would sort the numbered ahead of the unnumbered.
const nos = rows.map((r) => r.t.trackNo);
if (nos.every((x) => typeof x === "number" && x > 0)
&& new Set(nos).size === nos.length) {
return [...rows].sort((x, y) => x.t.trackNo - y.t.trackNo);
}
// Otherwise the filename, compared with its digit runs as NUMBERS —
// which is the same comparison the fold already uses to order parts.
return [...rows].sort((x, y) => x.t.name.localeCompare(
y.t.name, undefined, { numeric: true, sensitivity: "base" }));
});
/** What the file calls the track. The tag first — it is the thing apply
* writes and compares — falling back to the filename for a file carrying
* no title at all. */
const titleOfTrack = (t) => (t.title ?? "").trim()
|| t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const trackPairs = $derived.by(() => {
const theirs = candidate?.tracklist ?? [];
return a.tracks.map((t, n) => {
const mine = t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const their = theirs[n];
return trackRows.map(({ t, n }, row) => {
const mine = titleOfTrack(t);
const their = theirs[row];
const dY = t.seconds ?? null;
const dT = their?.secs ?? null;
const drift = dY && dT ? Math.abs(dY - dT) : null;
return { n, mine, their: their?.title ?? null, dY, dT, drift,
return { n, row, mine, their: their?.title ?? null, dY, dT, drift,
aligned: drift !== null && drift <= 2 };
});
});
@@ -542,8 +655,15 @@
const tags = {};
for (const [k, , theirs] of tagPairs)
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
const trackTitles = trackPairs.map((p) =>
p.their && resolvedTrack(p.n) === "theirs" ? p.their : null);
// Built by SCAN index, never by row: apply matches `trackTitles`
// position-for-position against its own sorted file list
// (import.rs:1238), and the rows above are ordered for reading. Writing
// this in row order would land every chosen title on the wrong file —
// silently, and on the user's actual music.
const trackTitles = a.tracks.map(() => null);
for (const p of trackPairs) {
if (p.their && resolvedTrack(p.n) === "theirs") trackTitles[p.n] = p.their;
}
onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks });
});
</script>
@@ -718,7 +838,9 @@
<div class="im-ab-find">
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
<small>{audition?.status === "none"
? "No peer offered this album just now — worth asking again another day."
? audition.silent
? "Soulseek answered nothing at all — the server refuses some queries outright, and quiet spells happen. Worth asking again."
: "No peer offered this album just now — worth asking again another day."
: audition?.status === "failed"
? "The peer stopped answering — try the search again."
: "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."}</small>
@@ -864,7 +986,7 @@
<div class="im-tl-h">{candidate.source}</div>
<div class="im-tl-h"></div>
{#each trackPairs as p (p.n)}
<div class="im-tl-n">{p.n + 1}</div>
<div class="im-tl-n">{p.row + 1}</div>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "yours"}
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}
+2
View File
@@ -55,6 +55,8 @@
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""}</span>
{/if}
{:else if importStatus.error}
<span>{importStatus.error} <b>Click to fix</b></span>
{:else}
<span>The import hit a problem — <b>click to see</b></span>
{/if}
+464 -19
View File
@@ -29,6 +29,11 @@
let step = $state("source");
let root = $state(null); // the folder being imported
// Where `root` sits relative to the LIBRARY folder. An absolute path is
// only true on the machine that wrote it; this is what survives the drive
// being carried somewhere else and coming back at another letter.
let rootRel = $state(null);
let rootMissing = $state(false); // saved source folder could not be found
let dest = $state(null); // { dir, freeBytes, layout } from the backend
let scan = $state(null); // ScanResult
let scanProg = $state(null); // latest per-file tick; running totals ride along
@@ -93,7 +98,7 @@
return [rel, rest];
}));
await api.importSessionSet(JSON.stringify({
step, root, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
step, root, rootRel, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
}));
} catch (e) {
@@ -108,8 +113,29 @@
if (!raw) return;
const v = JSON.parse(raw);
if (v?.root) {
root = v.root;
// The saved path first — nothing has moved in the common case.
// Then the same folder relative to where the library is NOW,
// which is how a review saved at one machine finds its source
// again at another. Both `root` and the scan's own copy are
// repointed: apply passes `scan.root`, so leaving that stale
// would resume onto a folder that is not there.
// NOT `found` — that name is the identify-results state, and
// shadowing it here throws on the assignment further down.
const liveRoot = await api
.importResolveRoot(v.root, v.rootRel ?? null)
.catch(() => null);
root = liveRoot ?? v.root;
// A session saved before the portable form existed learns it
// now, while its source is still findable — waiting until the
// next save would strand any review already in progress.
rootRel = v.rootRel
?? (liveRoot ? await api.importPortableRoot(liveRoot).catch(() => null) : null);
rootMissing = !liveRoot;
scan = v.scan ?? null;
if (scan && liveRoot) scan.root = liveRoot;
// Learned or repointed: write it back now rather than waiting
// for a transition that may never come.
const learned = rootRel !== (v.rootRel ?? null) || root !== v.root;
layoutChoice = v.layout ?? null;
choices = v.choices ?? {};
found = v.found ?? {};
@@ -123,6 +149,7 @@
auditions = v.auditions ?? {};
keepSeparate = v.keepSeparate ?? [];
step = v.step === "scanning" ? "source" : (v.step ?? "source");
if (learned) schedulePersist();
}
} catch (e) {
console.warn("import: session restore failed", e);
@@ -161,8 +188,7 @@
// The dev seed ({ root } form) still overrides the source on open.
$effect(() => {
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
root = ui.importOpen.root;
scan = null;
setRoot(ui.importOpen.root);
step = "source";
}
});
@@ -206,9 +232,18 @@
}
// ---- source ----------------------------------------------------------
/** Every route to a source folder goes through here, so the portable form
* is recorded at the same moment as the absolute one and cannot drift. */
async function setRoot(p) {
root = p;
scan = null;
rootMissing = false;
rootRel = p ? await api.importPortableRoot(p).catch(() => null) : null;
}
async function pickSource() {
const p = await api.pickFolder().catch(() => null);
if (p) { root = p; scan = null; }
if (p) await setRoot(p);
}
async function pickDest() {
@@ -473,7 +508,9 @@
else
k = matchEvidence(
f.result.candidate,
artistOf.get(x.rel) ?? x.artistFolder,
// The artist the answer was found under — the folder,
// unless the tags found what the folder could not.
f.result.askedArtist ?? artistOf.get(x.rel) ?? x.artistFolder,
albumOf.get(x.rel) ?? x.title,
x.tracks.length,
).verdict;
@@ -708,6 +745,125 @@
}
}
/** Ask again for the tracks a found copy still owes us.
*
* Transfers do not survive the app; the files they wrote do. So a session
* restored after a crash, a power cut or an ordinary restart brings back
* an audition that LOOKS live and is not: `restStarted` is already true,
* so nothing ever asks for the tracks that never landed, and every future
* apply refuses the record for the same reason — "the found copy is still
* arriving" — with nothing arriving. Two records sat at 12 of 13 and 6 of
* 11 that way.
*
* The pen decides how far it got, not the counter: `arrived` is a running
* total kept by events, and those events stopped with the process that
* was listening for them. A count that was already right costs one stat
* per file to confirm; a count that was wrong is corrected before it can
* refuse anything again.
*
* Asked at most once per record per run. A peer that has gone away should
* cost one round of requests, not a fresh one every time the record is
* looked at — "Try another peer" is the answer to that, and it is a
* deliberate act. */
const resumeAsked = new Set();
async function resumeAudition(rel) {
const au = auditions[rel];
if (!au || resumeAsked.has(rel)) return;
const files = au.candidate?.files ?? [];
if (!files.length || ["searching", "none", "failed"].includes(au.status)) return;
resumeAsked.add(rel);
const peer = au.candidate.username;
let missing;
try {
missing = await api.importAuditionMissing(
rel,
files.map((f) => ({ path: f.path, size: f.size ?? 0 })),
);
} catch (e) {
console.warn("import: could not read the pen", e);
resumeAsked.delete(rel);
return;
}
// Anything could have happened across the await — a switch to another
// peer, a fresh search. Credit this answer only to the copy it is about.
const cur = auditions[rel];
if (!cur || cur.candidate?.username !== peer) return;
cur.expected = files.length;
cur.arrived = files.length - missing.length;
// WHICH files are owed, not just how many — the strip states a size
// only when it knows exactly which files make it up.
cur.missingIdx = missing;
if (!missing.length) {
// Everything is already here. The record was refused by a stale
// number, and nothing needs downloading to unstick it.
cur.status = "complete";
schedulePersist();
return;
}
for (const i of missing) {
try {
const { ids } = await startAudition(rel, cur.candidate, { from: i, count: 1 });
cur.ids = [...(cur.ids ?? []), ...ids];
} catch (e) {
console.warn("import: could not re-request a missing track", e);
break;
}
}
cur.status = cur.track1Path ? "ready" : "downloading";
cur.xfer = { ...(cur.xfer ?? {}), phase: "requested", at: Date.now() };
schedulePersist();
}
/** Ask again for every refusal that is a found copy still owed tracks.
*
* Called from BOTH applies. They refuse the same way and have to recover
* the same way: wiring only the full one left the picker's partial apply
* — the one that runs when you import from the review itself — repeating
* "it imports as soon as the rest land" over records nothing was asking
* for. No-ops on a refusal that is not about an audition. */
function resumeRefused(skipped) {
for (const s of skipped ?? []) resumeAudition(s.rel);
}
// Opening a record is when we find out whether its found copy is still
// owed anything — and the only moment a resumed transfer has somewhere to
// be seen. Deliberately not a sweep at window-open: that would put a
// dozen peers' worth of requests on the wire for records nobody is
// looking at. The refused set after an apply is handled there.
$effect(() => {
if (step !== "upgrades" || !open) return;
const rel = curPick?.rel;
const au = rel ? auditions[rel] : null;
const want = au?.candidate?.files?.length ?? 0;
if (!want || (au.arrived ?? 0) >= (au.expected ?? want)) return;
resumeAudition(rel);
});
/** Throw away what the abandoned peer left in the pen.
*
* The cancel stops its transfers; this removes what they already wrote.
* The pen is keyed by RECORD, not by peer, so without it the dropped
* copy's files sit beside the replacement's until the session is cleared
* — 189 MB of a stranger's Centipede Hz did exactly that, and apply filed
* all 17 tracks. Apply takes the chosen copy's files by name now, so this
* is about disk rather than correctness; the ORDER still matters, because
* a file written after the clear would be a stray nothing asked for.
*
* Deliberately unconditional: "another peer" means this copy is not the
* one, and a partial copy of a rip you have walked away from is not worth
* the gigabytes. The automatic switch only fires before track 1 lands, so
* it can never discard more than one unfinished file. */
async function clearPen(rel, why) {
try {
const freed = await api.importAuditionClear(rel);
if (freed) {
console.info(`import: pen cleared for ${rel} — ${Math.round(freed / 1e6)} MB (${why})`);
}
} catch (e) {
console.warn("import: could not clear the pen", e);
}
}
/** Stop waiting on this peer and drop to the next ranked copy.
*
* Cancels the transfers first — without that, a switch left the old peer
@@ -721,6 +877,7 @@
for (const id of au.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
await clearPen(rel, reason ?? "peer switched");
const [next, ...rest] = au.alts ?? [];
if (!next) {
auditions[rel] = { status: "none", dead, exhausted: true, reason };
@@ -751,10 +908,13 @@
for (const id of prev?.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
// A fresh search abandons the current copy just as squarely as a
// switch does; whatever answers will be a different peer's rip.
if (prev?.candidate) await clearPen(rel, "searched again");
auditions[rel] = { status: "searching", dead };
try {
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
const list = await findAlbumCopies({
const { copies, silent } = await findAlbumCopies({
artist: pickerArtist(x),
// The record's own name leads; its folder is the narrower
// fallback for when the tags are wrong or absent.
@@ -763,15 +923,18 @@
candidate: trusted ? found[rel]?.result?.candidate : null,
wantTracks: x.tracks.length,
});
if (!list.length) {
auditions[rel] = { status: "none", dead };
if (!copies.length) {
// `silent` separates "the network never answered" from "peers
// answered and none had it" — they read identically on the
// card otherwise, and the first one is not about this record.
auditions[rel] = { status: "none", dead, silent };
schedulePersist();
return;
}
await beginAudition(
rel,
slimCandidate(list[0]),
list.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
slimCandidate(copies[0]),
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
{ dead },
);
} catch (e) {
@@ -812,6 +975,7 @@
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
if (p.done) {
au.arrived = (au.arrived ?? 0) + 1;
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
if (idx === 0) {
au.track1Path = p.localPath;
au.status = "ready";
@@ -900,6 +1064,105 @@
};
}
/* ---- what the upgrade step is still waiting on ---------------------- */
/** Records whose found copy is not fully in the pen yet, plus ones that
* ARE and have not been imported — a finished copy belongs here too,
* because it is the thing standing between you and pressing Import.
*
* Deliberately not "every audition": a record you listened to and then
* kept your own copy of is not something you are waiting for. */
const arrivals = $derived(
Object.entries(auditions)
.flatMap(([rel, au]) => {
const files = au?.candidate?.files ?? [];
const want = au?.expected ?? files.length;
if (!files.length || !want) return [];
const have = Math.max(0, Math.min(au.arrived ?? 0, want));
const owed = want - have;
const theirs = choices[rel]?.audio === "theirs";
if (choices[rel]?.applied || (!owed && !theirs)) return [];
// Bytes only when the pen check told us WHICH files are owed.
// Owed-count times an average would be a number that looks
// measured and is not.
const idx = au.missingIdx;
return [{
rel, au, want, have, owed, theirs,
name: rel.split(/[\\/]/).pop() || rel,
peer: au.candidate.username,
live: liveOf(au),
bytes: idx ? idx.reduce((n, i) => n + (files[i]?.size ?? 0), 0) : null,
}];
})
.sort((a, b) => b.owed - a.owed || a.name.localeCompare(b.name)));
const arrivalsOwed = $derived(arrivals.filter((a) => a.owed > 0));
const arrivalFiles = $derived(arrivalsOwed.reduce((n, a) => n + a.owed, 0));
const arrivalStalled = $derived(arrivalsOwed.some((a) => a.live?.stalled));
const arrivalBytes = $derived(
arrivalsOwed.length && arrivalsOwed.every((a) => a.bytes != null)
? arrivalsOwed.reduce((n, a) => n + a.bytes, 0) : null);
let trayOpen = $state(false);
/** What one row says, in the language the picker already uses. Nothing
* here invents a state: every branch is something the transfer events
* actually said, or the plain absence of them — after a restart there is
* no `xfer` at all, and "asked, no answer yet" is the truth then. */
function arrivalWords(a) {
if (!a.owed) return { cls: "ok", label: `all ${a.want} cached`, detail: "ready to import" };
const x = a.live;
if (x?.stalled) {
const mins = Math.max(1, Math.round((x.quietMs ?? 0) / 60000));
return { cls: "stall", label: `nothing for ${mins} min`, detail: `${a.peer} stopped sending` };
}
if (x?.phase === "transferring") {
const mbs = (x.speedBps ?? 0) / 1e6;
return { cls: "move", label: "arriving", detail: mbs >= 0.05 ? `${mbs.toFixed(1)} MB/s` : "slowly" };
}
if (x?.place != null || x?.phase === "queued") {
return {
cls: "wait", label: `queued at ${a.peer}`,
detail: x.place != null ? `${nfmt(x.place)} ahead of you` : "nothing moving yet",
};
}
return { cls: "wait", label: `asked ${a.peer}`, detail: "no answer yet" };
}
/** The container is what the peer sent, the depth and bitrate are what the
* probe MEASURED of the file that landed. */
function arrivalFormat(a) {
const first = a.au.candidate.files?.[0];
const ext = String(first?.name ?? first?.path ?? "").split(".").pop().toUpperCase();
const p = a.au.probe;
if (a.au.candidate.lossless === a.au.candidate.trackCount) {
return p?.bitDepth ? `${ext} ${p.bitDepth}-bit` : ext;
}
const k = p?.bitrate ?? a.au.candidate.avgBitrate;
return k ? `${ext} ${nfmt(k)}k` : ext;
}
const arrivalInitials = (name) =>
name.replace(/^.*?[-–—]\s*/, "").split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase();
/** Jump the picker to a record in the tray. The lens can be narrower than
* the tray — a record filtered out of the current bucket is still one you
* are waiting for — so widen rather than fail silently. */
function goToArrival(rel) {
if (!seekTo(rel)) {
bucket = "all";
seekTo(rel);
}
trayOpen = false;
schedulePersist();
}
/** Refusals the tray is not already speaking for. A record whose found
* copy is still arriving is IN the tray — live, with the peer and the
* count — and saying it again in the past tense above is the same fact
* twice, the staler of the two winning the eye. */
const leftBehind = $derived(
(report?.skipped ?? []).filter((s) => !arrivals.some((a) => a.rel === s.rel)));
/** The picker's resolved snapshot — what apply writes. Stored beside the
* choices but NOT a "seen": looking is not deciding. */
function snapshot(rel, snap) {
@@ -1182,6 +1445,12 @@
if (c && !refused.has(rel)) c.applied = true;
if (refused.size) {
await persist();
// A refusal is usually "the found copy is short", and the copy
// is short because a transfer died. Ask for the rest NOW, so
// "it imports as soon as the rest land" is something that can
// actually happen — otherwise the next run refuses the same
// records for the same reason, forever.
resumeRefused(report.skipped);
} else {
await api.importSessionClear().catch(() => {});
importResume.active = false;
@@ -1221,6 +1490,17 @@
// copy out of the source folder.
audio: r.audio === "theirs" ? "theirs" : null,
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : null,
// WHICH files the chosen copy is made of. The pen is
// keyed by RECORD, not by peer, so a record that
// changed peers has both copies sitting in it — the
// abandoned peer's transfers were cancelled, but the
// files they had already written stayed. Apply took
// everything in the folder and filed Centipede Hz as
// 17 tracks: deletia's 11 plus the 6 smash2 had
// managed before it was dropped.
audioFiles: r.audio === "theirs"
? (auditions[p.rel]?.candidate?.files ?? []).map((f) => f.path)
: null,
},
};
});
@@ -1231,7 +1511,17 @@
* doesn't redo them. The eventual full apply still covers everything —
* idempotent copies make the banked files no-ops. */
async function applyPartial() {
if (!dest?.dir || applying || !scan) return;
// Banking on the way out must never fail in silence. Closing the
// picker after deciding 25 records and finding nothing in the library —
// with no toast, no error and nothing in the log — is what a bare
// `return` here looks like from the outside.
if (applying || !scan) return;
if (!dest?.dir) {
importStatus.phase = "error";
importStatus.error = "No library folder is set, so nothing could be imported.";
console.warn("import: banking skipped — no destination");
return;
}
const seen = new Set(Object.entries(choices)
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
if (!seen.size) return;
@@ -1255,6 +1545,7 @@
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
for (const r of seen)
if (choices[r] && !refused.has(r)) choices[r].applied = true;
resumeRefused(rep.skipped);
importStatus.done = rep.albums;
importStatus.label = fmtGB(rep.bytes);
importStatus.skipped = refused.size;
@@ -1297,6 +1588,8 @@
}
step = "source";
root = null;
rootRel = null;
rootMissing = false;
scan = null;
scanProg = null;
scanEta = null;
@@ -1364,14 +1657,14 @@
show it on, so without this the only trace of a refusal was
a toast reading "Imported 0 records". Not dismissible while
it is still true — it clears itself when they import. -->
{#if step !== "done" && report?.skipped?.length}
{#if step !== "done" && leftBehind.length}
<div class="im-left">
<b>{nfmt(report.skipped.length)} record{report.skipped.length === 1 ? "" : "s"}
{report.skipped.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {report.skipped.length === 1 ? "it" : "them"} and
{report.skipped.length === 1 ? "it is" : "they are"} still on the list —
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
importing again once the reason below is gone will pick
{report.skipped.length === 1 ? "it" : "them"} up.
{leftBehind.length === 1 ? "it" : "them"} up.
<div class="im-left-rows">
<!-- The FOLDER, not the tag album name: this record's
tags call it "user48736353001", which names
@@ -1397,7 +1690,13 @@
<div class="im-drop-t">
<div class="im-drop-h">The music — read from</div>
<div class="im-path">{root ?? "Choose a folder…"}</div>
{#if scan}
{#if rootMissing}
<!-- A resumed review whose source could not be
found: say so HERE, where the folder is
named, rather than letting apply fail on
a path the review still displays. -->
<small class="missing">this folder isn't here — choose where it lives now</small>
{:else if scan}
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
{fmtGB(totals.bytes)} · scanned</small>
{:else if root}
@@ -1638,7 +1937,83 @@
{label}<i>{nfmt(n)}</i>
</button>
{/each}
<!-- Not a lens: the outcome chips choose what the picker
WALKS, this one discloses what the found copies still
owe. It rides here because it belongs to the same
glance, and it costs no space at all when nothing is
owed — which is the normal case. -->
{#if arrivals.length}
<button class="im-bucket ar-chip" class:stalled={arrivalStalled}
aria-expanded={trayOpen}
onclick={() => (trayOpen = !trayOpen)}
title={arrivalFiles
? "What the found copies still owe"
: "Copies cached and waiting to be imported"}>
{#if arrivalFiles}<span class="ar-dot"></span>{/if}
{arrivalStalled ? "Stalled" : arrivalFiles ? "Arriving" : "Arrived"}
<i>{nfmt(arrivalFiles || arrivals.length)}</i>
</button>
{/if}
</div>
{#if trayOpen && arrivals.length}
<div class="ar-tray" class:stalled={arrivalStalled} class:done={!arrivalFiles}>
<div class="ar-h">
{#if !arrivalFiles}
<b>Everything arrived.</b>
{arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`}
cached — importing now takes {arrivals.length === 1 ? "it" : "them"}.
{:else if arrivalStalled}
<b>Stalled.</b> {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}
still owed, and nothing is moving.
{:else}
<b>Still arriving.</b> {nfmt(arrivalsOwed.length)}
record{arrivalsOwed.length === 1 ? "" : "s"} ·
{nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} ·
{nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}.
{/if}
</div>
<div class="ar-rows">
{#each arrivals as a (a.rel)}
{@const w = arrivalWords(a)}
<div class="ar-row">
<div class="ar-art" class:ok={!a.owed}>{arrivalInitials(a.name)}</div>
<div class="ar-nm">
<b title={a.rel}>{a.name}</b>
<div class="ar-meta">
<div class="im-bar" class:live={a.owed > 0 && a.live?.phase === "transferring"}>
<i style:width="{Math.round((a.have / a.want) * 100)}%"></i>
</div>
<span>{nfmt(a.have)} of {nfmt(a.want)} cached</span>
<span>·</span><span>{arrivalFormat(a)}</span>
<b class="ar-state {w.cls}">{w.label}</b>
<span>{w.detail}</span>
</div>
</div>
<div class="ar-acts">
<button class="pill ghost" onclick={() => goToArrival(a.rel)}>Go to record</button>
{#if a.owed}
<button class="pill ghost"
onclick={() => switchPeer(a.rel, "you asked for another peer")}
>Try another peer</button>
{/if}
</div>
</div>
{/each}
</div>
<div class="ar-foot">
{#if arrivalFiles}
Nothing here needs you — they import themselves on the next run once the
files land. "Keep mine" on the record takes your own copy instead.
{:else}
<!-- The picker's own bank, not the full run:
it applies what has been decided and not
yet written, which is exactly these. -->
<button class="pill" onclick={applyPartial} disabled={applying}>
Import {nfmt(arrivals.length)} record{arrivals.length === 1 ? "" : "s"}</button>
{/if}
</div>
</div>
{/if}
{#if curPick}
{#key curPick.rel}
<ImportPicker
@@ -2117,6 +2492,72 @@
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
.im-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- what the found copies still owe ------------------------------
The chip rides in the outcome row but is NOT one of them: the others
choose what the picker walks, this one discloses a state. Accent fill
and a pulse say so, and it is absent entirely when nothing is owed. */
.im-bucket.ar-chip {
background: color-mix(in srgb, var(--accent) 26%, rgba(10, 10, 12, 0.5));
color: #fff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.im-bucket.ar-chip:hover {
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
}
.im-bucket.ar-chip.stalled {
background: rgba(233, 185, 73, 0.2); color: #f0cd77;
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.4);
}
.ar-dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
animation: ar-pulse 1.4s ease-in-out infinite;
}
@keyframes ar-pulse { 50% { opacity: 0.25; } }
.ar-tray {
position: relative; z-index: 1;
margin: -6px 0 16px; padding: 11px 13px; border-radius: var(--radius);
max-width: 96ch;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.ar-h {
display: flex; align-items: baseline; gap: 9px;
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
}
.ar-h b { color: color-mix(in srgb, var(--accent) 55%, #fff); font-weight: 800; }
.ar-tray.stalled .ar-h b { color: #f0cd77; }
.ar-tray.done .ar-h b { color: #7fe0a4; }
.ar-rows { margin-top: 9px; display: flex; flex-direction: column; gap: 7px; }
.ar-row {
display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
padding: 8px 9px; border-radius: 6px; background: rgba(255, 255, 255, 0.03);
}
.ar-art {
width: 34px; height: 34px; border-radius: 4px; display: grid; place-items: center;
font-size: 10px; font-weight: 800; color: rgba(255, 255, 255, 0.78);
background: color-mix(in srgb, var(--accent) 30%, rgba(255, 255, 255, 0.06));
}
.ar-art.ok { background: rgba(127, 224, 164, 0.22); }
.ar-nm { min-width: 0; }
.ar-nm > b {
display: block; font-size: 13px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ar-meta {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-top: 3px; font-size: 11.5px; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.ar-meta .im-bar { width: 120px; flex: none; }
.ar-state { font-weight: 700; }
.ar-state.wait { color: var(--text-secondary); }
.ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); }
.ar-state.stall { color: #f0cd77; }
.ar-state.ok { color: #7fe0a4; }
.ar-acts { display: flex; gap: 6px; align-items: center; }
.ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
.im-left {
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
@@ -2137,6 +2578,9 @@
}
.im-reset b { color: #f0a0a0; }
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
/* The source folder a resumed review could not find. Amber, not red: it
reports something that happened rather than asking to destroy anything. */
.im-drop-t small.missing { color: rgb(232, 178, 96); }
.pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
@@ -2177,5 +2621,6 @@
@media (prefers-reduced-motion: reduce) {
.im-bar.live i { animation: none; }
.led-pulse { animation: none; }
.ar-dot { animation: none; }
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script>
// Fullscreen lyrics: the whole window washed in the playing record's
// accent, the same LyricsPane rendered at reading-room scale. Opened from
// the pane's expand button; Escape or ✕ returns to the app.
import { ui, player, theme } from "../lib/state.svelte.js";
import LyricsPane from "./LyricsPane.svelte";
import Icon from "./Icon.svelte";
const artist = $derived.by(() => {
const track = player.track;
if (!track) return "";
return (track.artist ?? "").trim() || String(track.sub ?? "").split(" — ")[0].trim();
});
function onkeydown(e) {
if (e.key === "Escape") ui.lyricsFull = false;
}
// Close with the track when playback stops entirely — a lyrics room with
// nothing playing is a dead end.
$effect(() => {
if (ui.lyricsFull && !player.track) ui.lyricsFull = false;
});
</script>
<svelte:window onkeydown={ui.lyricsFull ? onkeydown : undefined} />
{#if ui.lyricsFull}
<div class="room" style="--wash: {theme.accent}">
<header>
<div class="who">
<p class="t">{player.track?.title ?? ""}</p>
{#if artist}<p class="a">{artist}</p>{/if}
</div>
<button class="close" title="Close (Esc)" onclick={() => (ui.lyricsFull = false)}>
<Icon name="shrink" size={18} />
</button>
</header>
<div class="body">
<LyricsPane big />
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 90% at 20% 0%, color-mix(in srgb, var(--wash) 32%, transparent), transparent 60%),
radial-gradient(120% 90% at 90% 100%, color-mix(in srgb, var(--wash) 14%, transparent), transparent 55%),
var(--bg-base);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 38px 10px;
flex: none;
}
.who {
min-width: 0;
}
.t {
font-size: 17px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
flex: none;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.body {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
padding: 6px 38px 30px;
}
.body :global(.lyr) {
width: min(820px, 100%);
height: 100%;
}
</style>
+911
View File
@@ -0,0 +1,911 @@
<script>
// The Now Playing lyrics pane: synced lines that follow playback with the
// active line held at SEAT, click-to-seek, a sync nudge, a candidate
// picker and a paste door for tracks nothing automatic can cover. Also
// rendered by the fullscreen overlay in `big` mode — one component, two
// type scales.
import { player, ui, playLocalFile } from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
activeWordIndex,
totalOffsetMs,
nudgeOffset,
setAutoSync,
searchCandidates,
closeCandidates,
chooseCandidate,
savePastedLyrics,
resetLyrics,
openSyncEditor,
} from "../lib/lyrics.svelte.js";
import { lyricLinesOf } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { untrack } from "svelte";
let { big = false } = $props();
let box = $state(null);
let pasting = $state(false);
let pasteText = $state("");
let menuOpen = $state(false);
// While the user is scrolling the box themselves, auto-follow stands
// down; it resumes a few seconds after the wheel goes quiet.
let holdUntil = 0;
// Load on mount and again whenever the playing track changes while the
// pane is visible. noteTrackChange clears stale lines synchronously, and
// an open paste box or menu goes with them — a paste typed for one song
// must never be saved under the identity of the next.
let shownKey = null;
$effect(() => {
const identity = lyricIdentity();
const key = identity ? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}` : "";
if (key !== shownKey) {
shownKey = key;
pasting = false;
pasteText = "";
menuOpen = false;
}
// untrack: ensureLyrics reads AND writes lyricsStore.status/key
// synchronously, so tracked it makes a failed fetch retrigger this
// effect forever (error → loading → error…) — the exact boundary
// NowPlaying's loadDiscovery documents. The effect keys off the
// playing IDENTITY alone.
untrack(() => {
noteTrackChange();
void ensureLyrics();
});
});
const active = $derived(
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
);
// ---- instrumental (offline vocal reduction) ----
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
const canInstrumental = $derived(
!!currentItem?.path && !currentItem?.stream && lyricsStore.status !== "loading",
);
let instrumentalBusy = $state(false);
let instrumentalNote = $state("");
async function playInstrumental() {
const item = currentItem;
if (instrumentalBusy || !item?.path || item.stream) return;
instrumentalBusy = true;
instrumentalNote = "";
try {
const res = await api.lyricsPrepareInstrumental(item.path);
// The prepare can take seconds; if the queue moved on meanwhile,
// starting the OLD track's instrumental would hijack whatever is
// playing now.
const current = player.queue?.[player.queueIndex] ?? null;
if (current?.path !== item.path) {
instrumentalNote = "The track changed while preparing — ready under ⋯ next time.";
return;
}
menuOpen = false;
const artist =
(item.artist ?? "").trim() || String(item.sub ?? "").split(" — ")[0].trim();
// The ORIGINAL title on purpose: the lyric identity stays with the
// recording, so the words keep following the instrumental.
await playLocalFile(res.path, item.title, `${artist} — instrumental`, item.art ?? null);
} catch (e) {
instrumentalNote = String(e).replace(/^Error:\s*/, "").slice(0, 140);
} finally {
instrumentalBusy = false;
}
}
// Where the active line is held inside the box: 0 = its top edge, 1 = its
// bottom. Above centre on purpose — dead centre spends half the box on
// words already sung, and the lead spacer it needed marooned a song's
// first lines below a screenful of nothing. The stylesheet's .tail-space
// is this value's complement, so the LAST line still reaches the same
// seat as every other one.
const SEAT = 0.42;
// Follow the active line, held at SEAT, unless the user is exploring.
$effect(() => {
const index = active;
if (index < 0 || !box || lyricsStore.status !== "synced") return;
if (performance.now() < holdUntil) return;
const el = box.querySelector(`[data-line="${index}"]`);
if (!el) return;
// Measured against the BOX, not offsetTop. offsetTop counts from
// offsetParent, which here is .pane (its panein keyframes animate a
// transform, so Chromium hands it the role) — the toolbar's height
// rode along in every target, seating the line ~30px above wherever
// the number said. Rects and scrollTop are origin-independent, so
// SEAT means what it reads.
const top = box.scrollTop + (el.getBoundingClientRect().top - box.getBoundingClientRect().top);
const target = top - box.clientHeight * SEAT + el.offsetHeight / 2;
box.scrollTo({
top: Math.max(0, target),
behavior: ui.windowActive ? "smooth" : "auto",
});
});
function userScrolled() {
holdUntil = performance.now() + 3500;
}
function seekTo(line) {
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
async function submitPaste() {
const ok = await savePastedLyrics(pasteText);
if (ok) {
pasting = false;
pasteText = "";
}
}
const SOURCE_LABEL = {
sidecar: "from a lyrics file",
embedded: "from the audio file",
lrclib: "via LRCLIB",
amll: "via AMLL DB · word-synced",
picked: "picked on LRCLIB",
user: "pasted by you",
};
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
</script>
<div class="lyr" class:big>
{#if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
<div class="shimmer" aria-hidden="true">
<span style="width: 72%"></span>
<span style="width: 58%"></span>
<span style="width: 81%"></span>
<span style="width: 47%"></span>
</div>
{:else if lyricsStore.candidates !== null}
<div class="picker">
<div class="pick-head">
<span>Choose the right lyrics</span>
<button class="tool" title="Close" onclick={closeCandidates}>
<Icon name="x" size={14} />
</button>
</div>
{#if lyricsStore.candidatesLoading}
<p class="note">Searching LRCLIB…</p>
{:else if lyricsStore.candidates.length === 0}
<p class="note">Nothing found for this track name.</p>
{:else}
<div class="cands">
{#each lyricsStore.candidates as c (c.id)}
<button class="cand" onclick={() => chooseCandidate(c.id)}>
<span class="c-main">
<span class="c-t">{c.title}</span>
<span class="c-a">{c.artist}{c.album ? ` — ${c.album}` : ""}</span>
</span>
{#if c.synced}<span class="c-tag">synced</span>{/if}
{#if c.instrumental}<span class="c-tag inst">instrumental</span>{/if}
<span class="c-d">{fmtDur(c.durationSecs)}</span>
</button>
{/each}
</div>
{/if}
</div>
{:else if pasting}
<div class="paste">
<p class="note">
Paste lyrics — plain text, or LRC with <span class="mono">[mm:ss]</span> timestamps
for synced display.
</p>
<textarea bind:value={pasteText} spellcheck="false" placeholder="[00:12.00] First line…"></textarea>
<div class="paste-actions">
<button class="pbtn ghost" onclick={() => (pasting = false)}>Cancel</button>
<button class="pbtn" onclick={submitPaste} disabled={!pasteText.trim()}>Save</button>
</div>
</div>
{:else if lyricsStore.status === "synced"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""}</span>
{#if lyricsStore.autoOffsetMs !== 0}
<button
class="autochip"
title="Timing auto-corrected from this file's extra lead-in — click to turn off"
onclick={() => setAutoSync(false)}
>
auto {lyricsStore.autoOffsetMs > 0 ? "+" : ""}{(lyricsStore.autoOffsetMs / 1000).toFixed(1)}s
</button>
{/if}
<span class="nudge" title="Shift lyric timing">
<button class="tool" title="Lyrics earlier" onclick={() => nudgeOffset(-500)}>−</button>
<span class="off" class:on={lyricsStore.offsetMs !== 0}>
{lyricsStore.offsetMs === 0
? "sync"
: `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`}
</span>
<button class="tool" title="Lyrics later" onclick={() => nudgeOffset(500)}>+</button>
</span>
{@render tools()}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="lines"
bind:this={box}
onwheel={userScrolled}
ontouchmove={userScrolled}
>
<div class="lead-space"></div>
{#each lyricsStore.lines as line, index (index)}
<button
class="line"
class:on={index === active}
class:past={index < active}
class:gap={!line.text}
data-line={index}
onclick={() => seekTo(line)}
title="Play from here"
>
{#if index === active && line.words?.length}
<!-- Real word timing from the source: crisp per-word.
This is the ONLY karaoke mode. A line-level source
gets the whole-line highlight and nothing more —
a sweep interpolated across the line's window
tracked the layout, not the voice (it crawled
through instrumental gaps), so it read as broken.
Word times are carried, never invented (the
Noctis rule). -->
{@const sungTo = activeWordIndex(line, player.position ?? 0)}
<span class="wline">
{#each line.words as word, wi (wi)}
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
{/each}
</span>
{:else}
{line.text || "· · ·"}
{/if}
</button>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "plain"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""} · not synced</span>
{@render tools()}
</div>
<button
class="syncnow"
title="Play the song once and tap each line as it begins"
onclick={() => openSyncEditor(lyricLinesOf(lyricsStore.plain))}
>
<Icon name="clock" size={14} /> Sync these lyrics
</button>
<!-- The same box, the same type, the same fade as the synced list —
a lyric sheet is a lyric sheet whether or not anything timed it.
Only the parts that need a clock are absent: no line is lit, and
none of them seek. -->
<div class="lines plain" bind:this={box}>
<div class="lead-space"></div>
{#each lyricsStore.plain.split("\n") as pline, i (i)}
<p class="line" class:gap={!pline.trim()}>{pline.trim() ? pline : "· · ·"}</p>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "instrumental"}
<div class="statebox">
<span class="state-glyph"><Icon name="music" size={26} /></span>
<p class="state-t">Instrumental</p>
<p class="state-s">Sit back — there are no words to follow.</p>
</div>
{:else if lyricsStore.status === "error"}
<div class="statebox">
<p class="state-t">Couldn’t look up lyrics</p>
<p class="state-s">Something failed along the way.</p>
<button class="pbtn" onclick={() => ensureLyrics(true)}>Try again</button>
</div>
{:else}
<div class="statebox">
<p class="state-t">No lyrics found</p>
<p class="state-s">
Nothing local and no LRCLIB match for this exact recording.
</p>
<div class="state-actions">
<button class="pbtn" onclick={searchCandidates}>
<Icon name="search" size={13} /> Search LRCLIB
</button>
<button class="pbtn ghost" onclick={() => (pasting = true)}>
<Icon name="file-text" size={13} /> Paste lyrics
</button>
</div>
</div>
{/if}
</div>
{#snippet tools()}
<span class="tools">
{#if !big}
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
<Icon name="expand" size={13} />
</button>
{/if}
<span class="tool-menu">
<button
class="tool"
title="Lyrics options"
onclick={(e) => {
e.stopPropagation();
menuOpen = !menuOpen;
}}
>
<Icon name="more-horizontal" size={15} />
</button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if canInstrumental}
<button class="mi" onclick={playInstrumental} disabled={instrumentalBusy}>
<Icon name="music" size={14} />
{instrumentalBusy ? "Preparing instrumental…" : "Play with reduced vocals"}
<span class="mi-hint">beta</span>
</button>
{#if instrumentalNote}
<p class="mi-note">{instrumentalNote}</p>
{/if}
<div class="mi-rule"></div>
{/if}
{#if lyricsStore.autoSyncOff}
<button
class="mi"
onclick={() => {
menuOpen = false;
setAutoSync(true);
}}
><Icon name="refresh" size={14} /> Re-enable auto-sync</button>
{/if}
{#if lyricsStore.status === "synced"}
<button
class="mi"
onclick={() => {
menuOpen = false;
openSyncEditor(lyricsStore.lines);
}}
><Icon name="clock" size={14} /> Re-time by hand</button>
{/if}
<button
class="mi"
onclick={() => {
menuOpen = false;
searchCandidates();
}}
><Icon name="search" size={14} /> Wrong lyrics? Search again</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
pasting = true;
}}
><Icon name="file-text" size={14} /> Paste your own</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
resetLyrics();
}}
><Icon name="refresh" size={14} /> Forget and look again</button>
</div>
{/if}
</span>
</span>
{/snippet}
<svelte:window onclick={menuOpen ? () => (menuOpen = false) : undefined} />
<style>
.lyr {
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── loading shimmer ── */
.shimmer {
display: flex;
flex-direction: column;
gap: 13px;
padding: 18px 4px;
}
.shimmer span {
height: 13px;
border-radius: 7px;
background: linear-gradient(
100deg,
var(--bg-highlight) 35%,
var(--bg-press) 50%,
var(--bg-highlight) 65%
);
background-size: 240% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
/* ── toolbar ── */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 2px 8px;
min-height: 30px;
}
.src {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* The label owns the slack: everything after it sits at the right. */
margin-right: auto;
}
.nudge {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--bg-highlight);
border-radius: 12px;
padding: 1px 4px;
flex: none;
}
.nudge .off {
min-width: 38px;
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.nudge .off.on {
color: var(--accent-hover);
}
.tools {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.tool {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.tool:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.tool-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 220px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 60;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.mi:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
/* ── synced lines ── */
.lines {
overflow-y: auto;
max-height: 46vh;
min-height: 180px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
}
.lines::-webkit-scrollbar {
width: 0;
display: none;
}
/* Just a breath above the first line — enough that the words clear the
toolbar's mask fade, not so much that a song opens on an empty box.
The tail is SEAT's complement (1 - 0.42), which is what lets the final
line scroll up to the same seat the rest of them ride at. */
.lead-space {
height: 7%;
flex: none;
}
.tail-space {
height: 58%;
flex: none;
}
.line {
display: block;
width: 100%;
text-align: left;
padding: 5px 6px;
border-radius: 7px;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
color: var(--text-muted);
transition:
color 0.25s ease,
transform 0.25s ease,
opacity 0.25s ease;
transform-origin: left center;
white-space: pre-wrap;
}
.line:hover {
color: var(--text-secondary);
background: var(--bg-highlight);
}
.line.past {
color: var(--text-secondary);
opacity: 0.55;
}
.line.on {
color: var(--text-primary);
transform: scale(1.03);
opacity: 1;
}
.line.gap {
color: var(--text-muted);
opacity: 0.4;
font-size: 12px;
letter-spacing: 0.3em;
}
.line.gap.on {
color: var(--accent-hover);
opacity: 0.8;
}
/* ── karaoke rendering inside the active line ──
Word mode: crisp steps on the source's own word times. */
.wline {
display: block;
}
.w {
color: var(--text-secondary);
transition: color 0.12s ease;
}
.w + .w {
margin-left: 0.32em;
}
.w.sung {
color: #fff;
}
.autochip {
flex: none;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
padding: 2.5px 8px;
border-radius: 9px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.autochip:hover {
background: color-mix(in srgb, var(--accent) 26%, transparent);
}
.mi:disabled {
opacity: 0.6;
}
.mi .mi-hint {
margin-left: auto;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding-left: 10px;
}
.mi-note {
font-size: 11px;
color: #e8a33d;
padding: 2px 10px 6px;
max-width: 240px;
}
.mi-rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
/* Fullscreen scale — same skeleton, bigger voice. */
.lyr.big .lines {
max-height: none;
height: 100%;
min-height: 0;
flex: 1;
}
.lyr.big .line {
font-size: clamp(22px, 3.1vw, 38px);
font-weight: 800;
line-height: 1.35;
padding: 9px 12px;
border-radius: 12px;
letter-spacing: -0.01em;
}
.lyr.big .line.gap {
font-size: 16px;
}
.lyr.big .toolbar {
padding-bottom: 14px;
}
.syncnow {
display: inline-flex;
align-items: center;
gap: 7px;
align-self: flex-start;
margin: 0 2px 10px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
}
.syncnow:hover {
background: var(--accent-hover);
}
/* ── plain text ──
Everything about the box, the type and the fade comes from .lines and
.line above; only the differences live here. Nothing is seekable, so
the rows stay quiet under the pointer, and with no line ever lit there
is no muted/active contrast to carry — the whole sheet reads at the
secondary tone the synced list gives a line once it has been sung.
The tail mirrors the lead rather than reserving a seat (SEAT's
complement) that nothing will ever scroll up to. */
.lines.plain .line {
color: var(--text-secondary);
cursor: default;
}
.lines.plain .line:hover {
background: none;
color: var(--text-secondary);
}
.lines.plain .tail-space {
height: 7%;
}
/* ── states ── */
.statebox {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 34px 12px 22px;
gap: 4px;
}
.state-glyph {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--bg-highlight);
color: var(--text-muted);
margin-bottom: 8px;
}
.state-t {
font-size: 14.5px;
font-weight: 700;
}
.state-s {
font-size: 12px;
color: var(--text-muted);
max-width: 260px;
}
.state-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pbtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 700;
margin-top: 4px;
}
.pbtn:hover:not(:disabled) {
background: var(--accent-hover);
}
.pbtn:disabled {
opacity: 0.5;
}
.pbtn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.pbtn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
/* ── candidate picker ── */
.pick-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
font-weight: 700;
color: var(--text-secondary);
padding: 2px 2px 8px;
}
.note {
font-size: 12px;
color: var(--text-muted);
padding: 4px 2px 8px;
}
.mono {
font-family: Consolas, monospace;
font-size: 11px;
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 4px;
color: var(--text-primary);
}
.cands {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 42vh;
overflow-y: auto;
scrollbar-width: none;
}
.cands::-webkit-scrollbar {
width: 0;
display: none;
}
.cand {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
border-radius: 7px;
text-align: left;
}
.cand:hover {
background: var(--bg-highlight);
}
.c-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.c-t {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-a {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-tag {
flex: none;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 7px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.c-tag.inst {
background: var(--bg-highlight);
color: var(--text-muted);
}
.c-d {
flex: none;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ── paste ── */
.paste textarea {
width: 100%;
min-height: 180px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.6;
outline: none;
}
.paste textarea:focus {
border-color: var(--accent);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@media (prefers-reduced-motion: reduce) {
.shimmer span {
animation: none;
}
.line {
transition: none;
}
}
</style>
+633
View File
@@ -0,0 +1,633 @@
<script>
// The tap-to-sync editor — the research bundle's "minimal correction
// editor", first scope: play the song and press Space (or click) as each
// line begins; nudge or ripple-shift stamps afterwards; optionally
// pre-fill ESTIMATED times from the measured vocal-activity envelope.
// Saving writes the track's own user document, which outranks every
// automatic source in the waterfall.
import { untrack } from "svelte";
import { player } from "../lib/state.svelte.js";
import {
syncEditor,
closeSyncEditor,
saveSyncEditor,
} from "../lib/lyrics.svelte.js";
import { roughSync } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { floatScroll } from "../lib/scrollbar.js";
let cursor = $state(0); // the next row a tap stamps
let ripple = $state(true); // ± adjustments shift later stamps too
let confirmDiscard = $state(false);
let saving = $state(false);
let saveError = $state("");
let activity = $state(null); // { frameMs, frames } | null
let activityFailed = $state(false);
let roughBusy = $state(false);
let listEl = $state(null);
// Position interpolation: ticks land ~5/s; a tap between ticks reads the
// last tick plus wall-clock elapsed, so stamps carry tap precision.
let lastPos = 0;
let lastPosAt = 0;
$effect(() => {
lastPos = player.position ?? 0;
lastPosAt = performance.now();
});
function nowMs() {
const base = (player.status === "playing"
? lastPos + (performance.now() - lastPosAt) / 1000
: (player.position ?? 0));
return Math.max(0, Math.round(base * 1000));
}
// Reset per open; pull the activity envelope for the strip + rough fill.
// Keyed on the open FLIP alone — the untrack boundary is load-bearing
// twice over. Without it this effect (a) read `cursor` back after writing
// it, so the all-stamped re-time path ping-ponged -1 ↔ rows.length until
// Svelte hit effect_update_depth_exceeded and tore the subtree down,
// leaving a painted room no click could close; and (b) depended on every
// row's time_ms through findIndex, so each tap re-ran the whole reset —
// wiping the discard guard and re-running the DSP decode per stamp.
$effect(() => {
if (!syncEditor.open) return;
untrack(() => {
const firstUnstamped = syncEditor.rows.findIndex((r) => r.time_ms == null);
cursor = firstUnstamped < 0 ? syncEditor.rows.length : firstUnstamped;
confirmDiscard = false;
activity = null;
activityFailed = false;
const path = syncEditor.itemPath;
if (path) {
api.lyricsVocalActivity(path)
.then((a) => (activity = a))
.catch(() => (activityFailed = true));
} else {
activityFailed = true;
}
});
});
const stampedCount = $derived(syncEditor.rows.filter((r) => r.time_ms != null).length);
const allStamped = $derived(
syncEditor.rows.length > 0 && stampedCount === syncEditor.rows.length,
);
function stampCursor() {
if (cursor >= syncEditor.rows.length) return;
syncEditor.rows[cursor].time_ms = nowMs();
syncEditor.estimated = false;
cursor += 1;
scrollCursorIntoView();
}
function stampRow(index) {
syncEditor.rows[index].time_ms = nowMs();
syncEditor.estimated = false;
cursor = Math.max(cursor, index + 1);
}
function unstampLast() {
const last = cursor - 1;
if (last < 0) return;
syncEditor.rows[last].time_ms = null;
cursor = last;
scrollCursorIntoView();
}
function adjustRow(index, deltaMs) {
const row = syncEditor.rows[index];
if (row.time_ms == null) return;
row.time_ms = Math.max(0, row.time_ms + deltaMs);
if (ripple) {
for (let i = index + 1; i < syncEditor.rows.length; i++) {
if (syncEditor.rows[i].time_ms != null) {
syncEditor.rows[i].time_ms = Math.max(0, syncEditor.rows[i].time_ms + deltaMs);
}
}
}
}
function scrollCursorIntoView() {
requestAnimationFrame(() => {
listEl
?.querySelector(`[data-row="${Math.min(cursor, syncEditor.rows.length - 1)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function roughFill() {
if (!activity || roughBusy) return;
roughBusy = true;
try {
const filled = roughSync(
syncEditor.rows.map((r) => r.text).join("\n"),
activity.frames,
activity.frameMs,
);
if (filled && filled.length === syncEditor.rows.length) {
for (let i = 0; i < filled.length; i++) {
syncEditor.rows[i].time_ms = filled[i].time_ms;
}
syncEditor.estimated = true;
cursor = 0; // re-tap from the top to correct
} else {
activityFailed = true; // not enough vocal evidence — refused
}
} finally {
roughBusy = false;
}
}
async function restartSong() {
await api.playerControl("seek", 0).catch(() => {});
if (player.status !== "playing") await api.playerControl("resume").catch(() => {});
}
function back5() {
api.playerControl("seek", Math.max(0, (player.position ?? 0) - 5)).catch(() => {});
}
function togglePlay() {
api.playerControl(player.status === "playing" ? "pause" : "resume").catch(() => {});
}
async function save() {
if (!allStamped || saving) return;
saving = true;
saveError = "";
try {
const ok = await saveSyncEditor();
if (!ok) {
saveError =
"Saving failed — if the app was just updated, restart it once so the sync backend is loaded.";
}
} finally {
saving = false;
}
}
function requestClose() {
if (stampedCount > 0 && !confirmDiscard) {
confirmDiscard = true;
return;
}
closeSyncEditor();
}
function onkeydown(e) {
if (e.key === " ") {
e.preventDefault();
e.stopPropagation();
stampCursor();
} else if (e.key === "Backspace") {
e.preventDefault();
unstampLast();
} else if (e.key === "Escape") {
requestClose();
}
}
// Playing another track mid-edit: stamps would land on the wrong clock.
const wrongTrack = $derived.by(() => {
if (!syncEditor.open) return false;
const item = player.queue?.[player.queueIndex] ?? null;
const title = player.track?.title ?? "";
return title !== (syncEditor.identity?.title ?? "") && !(item?.path === syncEditor.itemPath);
});
const playheadPct = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
if (!duration) return 0;
return Math.min(100, ((player.position ?? 0) / duration) * 100);
});
// The activity strip, downsampled to ~180 columns of inline SVG.
const strip = $derived.by(() => {
if (!activity?.frames?.length) return [];
const columns = 180;
const per = Math.max(1, Math.floor(activity.frames.length / columns));
const out = [];
for (let i = 0; i < activity.frames.length; i += per) {
let max = 0;
for (let j = i; j < Math.min(i + per, activity.frames.length); j++) {
max = Math.max(max, activity.frames[j]);
}
out.push(max);
}
return out;
});
const totalMs = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
return duration * 1000 || (activity ? activity.frames.length * activity.frameMs : 0);
});
function fmt(ms) {
if (ms == null) return "–:––";
const total = Math.floor(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
</script>
<svelte:window onkeydown={syncEditor.open ? onkeydown : undefined} />
{#if syncEditor.open}
<div class="room">
<header>
<div class="who">
<p class="eyebrow">Sync lyrics {syncEditor.estimated ? "· estimated — tap to correct" : ""}</p>
<p class="t">{syncEditor.identity?.title ?? ""}</p>
<p class="a">{syncEditor.identity?.artist ?? ""}</p>
</div>
<div class="head-actions">
<span class="count">{stampedCount} / {syncEditor.rows.length} stamped</span>
<button class="btn" onclick={save} disabled={!allStamped || saving}>
{saving ? "Saving…" : "Save timing"}
</button>
<button class="close" title={confirmDiscard ? "Discard timing?" : "Close (Esc)"} onclick={requestClose}>
{#if confirmDiscard}<span class="discard">Discard?</span>{:else}<Icon name="x" size={17} />{/if}
</button>
</div>
</header>
{#if saveError}
<div class="warnbar">
<Icon name="alert" size={13} />
{saveError}
</div>
{/if}
{#if wrongTrack}
<div class="warnbar">
<Icon name="alert" size={13} />
Playback moved to another track — stamps follow the PLAYING clock. Play
“{syncEditor.identity?.title}” again to keep tapping.
</div>
{/if}
<div class="stripwrap" aria-hidden="true">
{#if strip.length}
<svg class="strip" preserveAspectRatio="none" viewBox="0 0 {strip.length} 40">
{#each strip as v, i (i)}
<rect x={i} y={40 - v * 38} width="0.85" height={v * 38} rx="0.3" />
{/each}
{#each syncEditor.rows as row (row)}
{#if row.time_ms != null && totalMs}
<rect
class="mark"
x={(row.time_ms / totalMs) * strip.length}
y="0"
width="0.28"
height="40"
/>
{/if}
{/each}
</svg>
<span class="playhead" style="left: {playheadPct}%"></span>
{:else if activityFailed}
<p class="strip-note">
{syncEditor.itemPath
? "No usable vocal-band evidence for the estimate — tap by hand."
: "Streamed track — the activity strip and estimate need a local file."}
</p>
{:else}
<p class="strip-note">Reading the vocal band…</p>
{/if}
</div>
<div class="controls">
<button class="tbtn" title="Restart the song" onclick={restartSong}>
<Icon name="skip-back" size={14} />
</button>
<button class="tbtn" title="Back 5 seconds" onclick={back5}>−5s</button>
<button class="tbtn main" title="Play / pause" onclick={togglePlay}>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<span class="clock">{fmt((player.position ?? 0) * 1000)}</span>
<span class="spacer"></span>
<button
class="tbtn wide"
title="Estimate rough times from the vocal band, then correct by tapping"
onclick={roughFill}
disabled={!activity || roughBusy}
>
Rough-fill (estimated)
</button>
<label class="ripple">
<input type="checkbox" bind:checked={ripple} />
ripple ± to later lines
</label>
</div>
<p class="hint">
Press <span class="key">Space</span> (or click the line) as each line begins ·
<span class="key">Backspace</span> re-arms the previous line
</p>
<div class="rows" bind:this={listEl} use:floatScroll>
{#each syncEditor.rows as row, index (index)}
<div
class="row"
class:next={index === cursor}
class:stamped={row.time_ms != null}
data-row={index}
>
<button
class="stamp"
title={row.time_ms != null ? "Re-stamp at the playhead" : "Stamp at the playhead"}
onclick={() => stampRow(index)}
>
{fmt(row.time_ms)}
</button>
<span class="text">{row.text}</span>
{#if row.time_ms != null}
<span class="adj">
<button class="abtn" title="Earlier (−0.25s)" onclick={() => adjustRow(index, -250)}>−</button>
<button class="abtn" title="Later (+0.25s)" onclick={() => adjustRow(index, 250)}>+</button>
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 95;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 80% at 15% 0%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%),
var(--bg-base);
padding: 22px 34px 18px;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex: none;
}
.eyebrow {
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent-hover);
}
.t {
font-size: 20px;
font-weight: 800;
margin-top: 2px;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 1px;
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
flex: none;
}
.count {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.btn {
padding: 9px 18px;
border-radius: 18px;
background: var(--accent);
color: #000;
font-size: 13px;
font-weight: 800;
}
.btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn:disabled {
opacity: 0.45;
}
.close {
display: grid;
place-items: center;
min-width: 36px;
height: 36px;
border-radius: 18px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
padding: 0 10px;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.discard {
font-size: 12px;
font-weight: 800;
color: #f15e6c;
}
.warnbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 13px;
border-radius: 9px;
background: rgba(232, 163, 61, 0.13);
color: #e8a33d;
font-size: 12.5px;
font-weight: 700;
flex: none;
}
.stripwrap {
position: relative;
height: 52px;
margin-top: 14px;
border-radius: 8px;
background: var(--bg-elevated);
overflow: hidden;
flex: none;
}
.strip {
position: absolute;
inset: 4px 6px;
width: calc(100% - 12px);
height: calc(100% - 8px);
}
.strip rect {
fill: color-mix(in srgb, var(--accent) 55%, transparent);
}
.strip rect.mark {
fill: #fff;
opacity: 0.85;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1.5px;
background: #fff;
opacity: 0.9;
}
.strip-note {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 12px;
color: var(--text-muted);
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex: none;
}
.tbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 38px;
height: 34px;
padding: 0 12px;
border-radius: 17px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 700;
}
.tbtn:hover:not(:disabled) {
background: var(--bg-press);
color: var(--text-primary);
}
.tbtn:disabled {
opacity: 0.45;
}
.tbtn.main {
background: var(--accent);
color: #000;
}
.tbtn.main:hover {
background: var(--accent-hover);
}
.clock {
font-size: 12.5px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 44px;
}
.spacer {
flex: 1;
}
.ripple {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.ripple input {
accent-color: var(--accent);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
flex: none;
}
.key {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
background: var(--bg-highlight);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
}
.rows {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-top: 10px;
padding-right: 8px;
}
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 7px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.row.next {
background: var(--accent-soft);
}
.row.next .text {
color: var(--text-primary);
font-weight: 800;
}
.stamp {
flex: none;
width: 64px;
text-align: center;
padding: 4px 0;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.row.stamped .stamp {
background: var(--accent-soft);
color: var(--accent-hover);
}
.stamp:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.text {
flex: 1;
min-width: 0;
font-size: 15.5px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.45;
}
.row.stamped .text {
color: var(--text-primary);
}
.adj {
display: none;
gap: 4px;
flex: none;
}
.row:hover .adj {
display: inline-flex;
}
.abtn {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 14px;
font-weight: 800;
}
.abtn:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>
+35 -3
View File
@@ -28,6 +28,8 @@
} from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
import LyricsPane from "./LyricsPane.svelte";
import PlaylistMenu from "./PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import { floatScroll } from "../lib/scrollbar.js";
import { splitDescription } from "../lib/editions.js";
@@ -1087,6 +1089,27 @@
{metaParts[1] || player.track?.title || "Nothing playing"}
</span>
</div>
{#if currentItem}
<PlaylistMenu
compact
align="right"
iconSize={15}
title="Add this track to a playlist"
tracks={() => [
{
title: currentItem.title,
artist: currentItem.artist ?? metaParts[0] ?? null,
album: currentItem.album ?? metaParts[1] ?? null,
duration_secs: currentItem.duration ?? null,
// A stream item's path names a file on the PEER's
// machine — the row goes in as a placeholder that
// streams on click, not as a fake local file.
path: currentItem.stream ? null : currentItem.path,
art_path: currentItem.stream ? null : (currentItem.art ?? null),
},
]}
/>
{/if}
<!-- Saving what you are streaming lived in the popover. It is the
only ACTION that did, so it comes up here rather than being
lost with it. -->
@@ -1272,9 +1295,7 @@
{/if}
{:else if pane === "lyrics"}
<p class="pane-empty">
Lyrics aren't wired up yet — this pane is a placeholder.
</p>
<LyricsPane />
{:else if pane === "artist"}
{@render artistChip()}
@@ -1825,6 +1846,17 @@
color: var(--text-secondary);
}
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
/* The add-to-playlist trigger wears the header-icon look exactly. */
.np-head :global(.plm-trigger) {
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.np-head :global(.plm-trigger:hover) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.np-scroll {
/* Fills the absolutely-positioned .full-view rather than sizing to its
+554
View File
@@ -0,0 +1,554 @@
<script>
// Staged playlist import: parse → resolve → REVIEW → one-transaction
// commit. The preview names what matched and what did not; unresolved
// rows stay visible placeholders unless the user explicitly leaves them
// out. Nothing touches the database until "Create playlist".
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
draft = null,
onimported = () => {},
} = $props();
let pasteText = $state("");
let localDraft = $state(null);
let name = $state("");
let includeUnresolved = $state(true);
let committing = $state(false);
let parsing = $state(false);
let error = $state("");
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
// Re-arm the form whenever the modal opens with a new draft.
$effect(() => {
if (!open) return;
error = "";
committing = false;
localDraft = null;
pasteText = "";
includeUnresolved = true;
name = draft?.name ?? "";
});
async function previewPaste() {
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
localDraft = parsed;
if (!name) name = parsed.name ?? "";
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
const kept = $derived.by(() => {
if (!working) return [];
return includeUnresolved
? working.entries
: working.entries.filter((e) => e.resolution === "local");
});
async function commit() {
if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim();
committing = true;
error = "";
try {
const id = await api.playlistImportCommit(
finalName,
working.description ?? null,
working.kind,
kept,
);
onimported(id, finalName);
} catch (e) {
error = String(e);
committing = false;
}
}
function close() {
open = false;
}
function fmtTime(ms) {
if (ms == null) return "";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
: `${m}:${String(s).padStart(2, "0")}`;
}
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function onkeydown(e) {
if (e.key === "Escape") close();
}
const FORMAT_LABEL = {
m3u: "M3U",
pls: "PLS",
xspf: "XSPF",
csv: "CSV",
soulstream: "SoulStream backup",
text: "Pasted text",
folder: "Folder",
};
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={close} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-label="Import playlist"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<header>
<h2>
{#if working}
Review import
{:else}
Paste a tracklist
{/if}
</h2>
<button class="x" title="Close" onclick={close}>
<Icon name="x" size={16} />
</button>
</header>
{#if !working}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
spellcheck="false"
></textarea>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
</button>
</footer>
{:else}
<div class="srcline">
<span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span>
{#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if}
<span class="srcname" title={working.source_name}>{working.source_name}</span>
<span class="counts">
<b class="ok">{working.resolved_count}</b> matched
{#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved
{/if}
</span>
</div>
{#if working.warnings?.length}
<div class="warnings">
{#each working.warnings as w, wi (wi)}
<p><Icon name="alert" size={12} /> {w}</p>
{/each}
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
class="row"
class:unresolved={e.resolution !== "local"}
class:excluded={!includeUnresolved && e.resolution !== "local"}
>
<span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"}
<span class="t">{fmtTime(e.start_ms)}</span>
{/if}
<span class="who" title={e.raw}>
<span class="ti">{e.title}</span>
{#if e.artist}<span class="ar">{e.artist}</span>{/if}
</span>
<span class="ev">
{#if e.resolution === "local"}
<span
class="tag ok"
title={e.matched_by === "path"
? "Matched by exact file path"
: e.matched_by === "relinked"
? "File found under the current library folder"
: "Matched by artist and title tags"}
>
<Icon name="check" size={11} />
{e.matched_by === "path" || e.matched_by === "file"
? "file"
: e.matched_by === "relinked"
? "relinked"
: "tags"}
</span>
{:else}
<span class="tag un" title="No local match — kept as a placeholder">
unresolved
</span>
{/if}
</span>
<span class="du">{fmtDur(e.duration_secs)}</span>
</div>
{/each}
</div>
<div class="form">
<input
class="name"
bind:value={name}
placeholder="Playlist name"
spellcheck="false"
/>
{#if working.unresolved_count > 0}
<label class="keep">
<input type="checkbox" bind:checked={includeUnresolved} />
Keep unresolved entries as placeholders
</label>
{/if}
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
{/if}
<button
class="btn primary"
onclick={commit}
disabled={committing || kept.length === 0}
>
{committing
? "Creating…"
: `Create playlist (${kept.length} ${kept.length === 1 ? "track" : "tracks"})`}
</button>
</footer>
{/if}
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
backdrop-filter: blur(3px);
}
.dialog {
width: min(640px, calc(100vw - 64px));
max-height: min(78vh, 760px);
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h2 {
font-size: 18px;
font-weight: 700;
}
.x {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.x:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.lead {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
}
.mono {
font-family: Consolas, monospace;
font-size: 12px;
color: var(--text-primary);
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 5px;
}
textarea {
width: 100%;
min-height: 220px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
.srcline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
min-width: 0;
}
.chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--bg-highlight);
color: var(--text-secondary);
padding: 3px 8px;
border-radius: 8px;
flex: none;
}
.chip.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.srcname {
font-size: 12.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.counts {
margin-left: auto;
flex: none;
font-size: 12px;
color: var(--text-muted);
}
.counts .ok {
color: #3ecf7e;
}
.counts .un {
color: #e8a33d;
}
.warnings {
margin-bottom: 8px;
}
.warnings p {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #e8a33d;
}
.rows {
flex: 1;
min-height: 120px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-base);
padding: 4px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12.5px;
}
.row:hover {
background: var(--bg-highlight);
}
.row.excluded {
opacity: 0.35;
text-decoration: line-through;
}
.n {
width: 26px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.t {
width: 52px;
flex: none;
color: var(--accent-hover);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ti {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ar {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev {
flex: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
padding: 2.5px 7px;
border-radius: 8px;
}
.tag.ok {
background: rgba(62, 207, 126, 0.13);
color: #3ecf7e;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.du {
width: 40px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.form {
display: flex;
align-items: center;
gap: 14px;
margin-top: 12px;
}
.name {
flex: 1;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 13.5px;
font-weight: 600;
outline: none;
}
.name:focus {
border-color: var(--accent);
}
.keep {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
flex: none;
}
.keep input {
accent-color: var(--accent);
}
.error {
margin-top: 8px;
font-size: 12.5px;
color: #f15e6c;
}
footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
transition: background 0.14s;
}
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn.primary:disabled {
opacity: 0.5;
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script>
// "Add to playlist" dropdown — one component for every surface (album
// rail, track rows, Now Playing). Callers hand a THUNK that produces the
// rows when a target is picked, so building the payload costs nothing
// until the user actually commits.
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let {
// () => [{ title, artist, album, durationSecs, path, artPath }]
tracks,
align = "left", // "left" | "right"
iconSize = 13,
title = "Add to playlist",
compact = false, // icon-only trigger
} = $props();
let open = $state(false);
let busy = $state(false);
let doneFor = $state(null); // playlist id that just accepted rows
$effect(() => {
if (open && !playlistStore.loaded) refreshPlaylists();
});
function toggle(e) {
e.stopPropagation();
open = !open;
doneFor = null;
}
function payload() {
return (tracks() ?? []).map((t) => ({
title: t.title ?? "",
artist: t.artist ?? null,
album: t.album ?? null,
duration_secs: t.durationSecs ?? t.duration_secs ?? null,
path: t.path ?? null,
art_path: t.artPath ?? t.art_path ?? null,
}));
}
async function addTo(pl) {
if (busy) return;
const rows = payload().filter((t) => t.title);
if (!rows.length) return;
busy = true;
try {
await api.playlistAddItems(pl.id, rows);
doneFor = pl.id;
await refreshPlaylists();
setTimeout(() => (open = false), 650);
} catch (e) {
console.error("add to playlist failed", e);
} finally {
busy = false;
}
}
async function addToNew() {
if (busy) return;
const rows = payload().filter((t) => t.title);
if (!rows.length) return;
busy = true;
try {
// Name the new playlist after what is being added when it reads
// like an album; otherwise fall back to the numbered default.
const first = rows[0];
const name =
rows.length > 1 && first.album
? first.album
: rows.length === 1
? first.title
: `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await api.playlistAddItems(id, rows);
doneFor = id;
await refreshPlaylists();
setTimeout(() => (open = false), 650);
} catch (e) {
console.error("add to new playlist failed", e);
} finally {
busy = false;
}
}
function windowClick() {
open = false;
}
</script>
<svelte:window onclick={open ? windowClick : undefined} />
<div class="plm">
<button
class="plm-trigger"
class:compact
{title}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<Icon name="list-plus" size={iconSize} />
{#if !compact}<span>Add to playlist</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="plm-menu" class:right={align === "right"} role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="plm-item new" onclick={addToNew} disabled={busy}>
<Icon name="plus" size={14} /> New playlist
</button>
{#if playlistStore.playlists.length}
<div class="plm-rule"></div>
<div class="plm-list">
{#each playlistStore.playlists as pl (pl.id)}
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
{#if doneFor === pl.id}
<span class="ok"><Icon name="check" size={14} /></span>
{:else}
<Icon name="playlist" size={14} />
{/if}
<span class="nm">{pl.name}</span>
<span class="ct">{pl.track_count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.plm {
position: relative;
display: inline-flex;
}
.plm-trigger {
display: inline-flex;
align-items: center;
gap: 7px;
color: inherit;
}
.plm-trigger.compact {
display: grid;
place-items: center;
}
.plm-menu {
position: absolute;
top: calc(100% + 7px);
left: 0;
min-width: 210px;
max-width: 270px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 80;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.plm-menu.right {
left: auto;
right: 0;
}
.plm-list {
max-height: 260px;
overflow-y: auto;
}
.plm-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
}
.plm-item:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.plm-item:disabled {
opacity: 0.6;
}
.plm-item .nm {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plm-item .ct {
flex: none;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.plm-item .ok {
color: #3ecf7e;
display: grid;
place-items: center;
}
.plm-rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
</style>
+40
View File
@@ -13,6 +13,7 @@
goToView,
openRecentAlbum,
getArt,
playlistCoverFor,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -145,6 +146,15 @@
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
// Pinned playlists ride between the nav and the history — quick access
// to the lists the user chose to keep close, with their covers.
const pinnedPlaylists = $derived(
playlistStore.playlists.filter((p) => p.pinned).slice(0, 6),
);
$effect(() => {
for (const pl of pinnedPlaylists) void playlistCoverFor(pl);
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
@@ -226,6 +236,28 @@
</button>
{/each}
{#each pinnedPlaylists as pl (pl.id)}
<button
class="rec"
class:on={ui.view === "playlist" && ui.playlistId === pl.id}
title="{pl.name} — pinned playlist"
onclick={() => goToView("playlist", { playlistId: pl.id, label: pl.name })}
>
{#if playlistStore.covers[pl.id]}
<img src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="playlist" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
<span class="pin-mini" aria-hidden="true"><Icon name="pin" size={10} /></span>
{pl.name}
</span>
<span class="s">{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}</span>
</span>
</button>
{/each}
<div class="rule"></div>
{#each recentRows as row (row.key)}
@@ -698,6 +730,14 @@
opacity: 0;
}
/* Pinned playlist rows share the recents' whole construction; the only
addition is the tiny pin riding the name, which fades with the text. */
.pin-mini {
display: inline-flex;
color: var(--text-muted);
flex: none;
}
.eq {
display: inline-flex;
align-items: flex-end;
+111 -8
View File
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
// Rows are OCCURRENCES with their own ids: the same recording may appear
// twice, and every mutation addresses item ids, never positions.
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
export const setPlaylistDescription = (id, description) =>
invoke("set_playlist_description", { id, description: description ?? null });
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const playlistItems = (id) => invoke("playlist_items", { id });
export const playlistAddItems = (playlistId, items, at = null) =>
invoke("playlist_add_items", { playlistId, items, at });
export const playlistRemoveItems = (playlistId, itemIds) =>
invoke("playlist_remove_items", { playlistId, itemIds });
export const playlistSetOrder = (playlistId, orderedIds) =>
invoke("playlist_set_order", { playlistId, orderedIds });
// Repair one row: fields null = leave as stored; path non-null relinks the
// row to a playable file.
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
invoke("playlist_relink_item", {
playlistId,
itemId,
title: fields.title ?? null,
artist: fields.artist ?? null,
album: fields.album ?? null,
durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null,
artPath: fields.artPath ?? null,
});
// Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) =>
invoke("playlist_cover", { id, maxEdge });
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
// Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder");
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportCommit = (name, description, kind, entries) =>
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
// the written path, or null when the dialog was cancelled.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
export const lyricsGet = (args) =>
invoke("lyrics_get", {
title: args.title,
artist: args.artist,
album: args.album ?? null,
durationSecs: args.durationSecs ?? null,
path: args.path ?? null,
refresh: args.refresh ?? null,
});
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
export const lyricsSaveText = (title, artist, durationSecs, text) =>
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
export const lyricsClear = (title, artist, durationSecs) =>
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
// not a learned separator). Resolves to { path, cached } of a playable WAV.
export const lyricsPrepareInstrumental = (path) =>
invoke("lyrics_prepare_instrumental", { path });
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
// Persist hand-synced lines as the track's own document (wins the waterfall).
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
@@ -232,6 +303,11 @@ export const debugStats = () => invoke("debug_stats");
// source folder is never written to.
export const importScan = (root) => invoke("import_scan", { root });
export const importSessionGet = () => invoke("import_session_get");
// The source folder relative to the library folder, and back again. Saving
// both is what lets a review started on one machine resume on another after
// the drive lands at a different letter.
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
export const importSessionSet = (state) => invoke("import_session_set", { state });
export const importSessionClear = () => invoke("import_session_clear");
export const importGetDest = () => invoke("import_get_dest");
@@ -239,11 +315,29 @@ export const importSetDest = (dir, layout = null) => invoke("import_set_dest", {
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
// On-demand per-record identification (Deezer try-list → MB fallback) and
// the artwork panel's cover report. Both read-only.
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
// `title` is the record's own claim (tags); `folder` is the second witness,
// asked after it — a record whose tags are wrong still has its folder name.
// `shape` is everything ELSE the scan knows — track count, track titles, year,
// and where the files are so Rust can hash the record's own cover. That is what
// the discography pass compares when no phrase can find the record; the cover
// itself never crosses IPC (a master can be 8 MB).
export const importIdentify = (artist, title, folder = null, shape = {}) =>
invoke("import_identify", {
artist, title, folder,
tracks: shape.tracks ?? null,
trackTitles: shape.trackTitles ?? null,
year: shape.year ?? null,
sourceRoot: shape.sourceRoot ?? null,
rel: shape.rel ?? null,
// Bounds the cover hash to the record's own audio — a folded release
// gathers from these, an ordinary one from its folder alone.
parts: shape.parts ?? null,
});
export const importCoverReport = (artist, title, referenceUrl) =>
invoke("import_cover_report", { artist, title, referenceUrl });
// The record's own artwork, measured (embedded first, else folder images).
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
export const importLocalArt = (sourceRoot, rel, parts) =>
invoke("import_local_art", { sourceRoot, rel, parts });
// Prune rows whose files are gone, then index anything new — the real rescan.
export const rescanLibrary = () => invoke("rescan_library");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
@@ -252,6 +346,15 @@ export const rescanLibrary = () => invoke("rescan_library");
export const importAudition = (username, rel, files, startIndex = 0) =>
invoke("import_audition", { username, rel, files, startIndex });
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
// Which of a found copy's files the pen does not (fully) hold. Transfers die
// with the app and the arrived-count dies with them; the files on disk are
// what is left to ask.
export const importAuditionMissing = (rel, files) =>
invoke("import_audition_missing", { rel, files });
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
// The pen is keyed by RECORD, so without this the dropped peer's files sit
// beside the replacement's until the session is cleared.
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
// Measured facts about one audio file (the version card's quality chips).
export const importProbeFile = (path) => invoke("import_probe_file", { path });
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
// How the import finder asks when it is not ALLOWED to ask.
//
// The Soulseek server drops a search outright when the query carries every
// word of one of its excluded phrases. Measured 2026-08-11 against the live
// server, with the record's own copies provably on the network:
//
// animal collective merriweather 578 peers · 2,664 FLAC files
// merriweather pavilion 683 peers
// post pavilion 712 peers
// merriweather post pavilion 0 peers <- every time
// pavilion merriweather post 0 peers <- order is irrelevant
// merriweather post pavilion flac 0 peers <- extra terms don't help
//
// The published exclusions behave identically ("village people" 0, "bryan
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
// this is that same mechanism — but the server publishes only SOME of its
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
// unpublished one like this album's title reaches the wire and is swallowed.
//
// Nothing here can make the server answer a refused query. What it CAN do is
// ask a shorter question the server does answer, and then apply the part we
// were not allowed to say to the answers, where no server is involved.
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
* token nothing will match. */
export function queryWords(s) {
return String(s ?? "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.split(/\s+/)
.filter(Boolean);
}
/** The record's name with its last word dropped — the shortest change that
* breaks the refused word-set while still naming the record.
*
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
* is "the dark side of the", which asks the network for three words that
* carry no meaning and narrow the result set for nothing.
*
* Returns "" when there is nothing left worth asking. A one-word name
* shortens to nothing at all, and a bare artist query asks the network for
* that artist's entire discography — the same thing `findQueries` refuses
* to do. */
export function shortenName(name) {
const w = queryWords(name);
if (w.length < 2) return "";
w.pop();
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
return w.join(" ");
}
/** Does this peer's path claim to BE the record we could not ask for? Every
* word of the full name has to appear in it.
*
* This is the half of the question the server was not allowed to answer, and
* it is what keeps the shortened query honest: "animal collective merriweather
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
* the venue is in the path of every bootleg taped there.
*
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
* a folder that misspells the record is refused rather than guessed at. That
* costs a few real copies whose owners cannot spell; accepting near-misses
* costs correctness, and there is no way to tell the two apart from here. */
export function pathHasTitle(path, name) {
const need = queryWords(name);
if (!need.length) return false;
const have = new Set(queryWords(path));
return need.every((w) => have.has(w));
}
+30 -2
View File
@@ -27,8 +27,36 @@
*
* 1. Deezer try-list, MusicBrainz fallback.
* 2. + the artist's own catalogue, ranked by title similarity — finds records
* whose folder name no release carries ("1989 Pauls Boutique"). */
export const IDENTIFY_ENGINE = 2;
* whose folder name no release carries ("1989 Pauls Boutique").
* 3. + the artist the TAGS name, asked when the folder's artist found nothing
* or the wrong record, and + a try-list that refuses to strip a title down
* to its catalogue number ("Band Of Gypsys (SICP 2643)" → "(SICP 2643)").
* 4. + the title without a trailing release-type word ("Willoughby's Beach EP"
* → "Willoughby's Beach"). Both providers require every word of the
* phrase, so the one appended word hid a record each of them holds.
* 5. + the FOLDER as a second witness, asked after the record's own claim.
* Tags carry typos the folder does not ("Nonagon Infiniry" on all nine
* files, in a correctly-spelled folder), and until now one wrong tag was
* the only thing any provider was ever asked about.
* 6. + the record's TRACK COUNT breaks the tie when one title names several
* MusicBrainz release groups ("Murder of the Universe" is a 21-track Album
* and a 1-track Single, both scored 100, in an unstable order), and a
* provider request that FAILED no longer caches as "nothing found".
* 7. The last resort stopped being a title search dressed as a comparison. It
* ranks the artist's WHOLE MusicBrainz discography — complete, where
* Deezer's listing omitted EPs entirely — and decides on every fact the
* scan has: wording, track count, year, the TRACKLIST, and the record's own
* ARTWORK hashed against the discography's covers. A name is one fact about
* a record and the one most often wrong; `Rock Bottom 7"` scores 0.667
* against the release actually called "Rock Bottom / Octopus", and 2 of 2
* on its songs.
*
* BUMP THIS AFTER THE RUST BUILD LANDS, never before. Vite hot-reloads this
* constant instantly while the capability it describes lives in
* `metadata::identify_titles`, so bumping it against a running old binary
* makes that binary sweep the negatives and re-stamp the record on screen as
* freshly looked at by code that learned nothing. */
export const IDENTIFY_ENGINE = 7;
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
* value seen, which keeps the result stable for a re-render. */
+108
View File
@@ -0,0 +1,108 @@
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
// editor. Never presented as finished timing.
//
// The method deliberately stays above the research doc's forbidden floor
// ("never estimate line positions by evenly distributing text across song
// duration"): lines are laid out only across MEASURED vocal-active segments,
// weighted by line length, and when the measurement shows nothing usable the
// generator refuses instead of guessing. Real generated timing (ASR anchors
// + forced alignment, per the research) is a later, model-gated feature.
//
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
/** Hysteresis segmentation of a 0..1 activity envelope.
* Returns [{ startMs, endMs }], merged and pruned. */
export function activeSegments(frames, frameMs, opts = {}) {
const on = opts.on ?? 0.32; // enter singing
const off = opts.off ?? 0.16; // leave singing…
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
const minSegMs = opts.minSegMs ?? 1200;
const mergeGapMs = opts.mergeGapMs ?? 700;
const segments = [];
let start = null;
let belowRun = 0;
for (let i = 0; i < frames.length; i++) {
const v = frames[i];
if (start == null) {
if (v >= on) {
start = i;
belowRun = 0;
}
} else if (v < off) {
belowRun += frameMs;
if (belowRun >= offRunMs) {
const end = i * frameMs - belowRun + frameMs;
segments.push({ startMs: start * frameMs, endMs: end });
start = null;
belowRun = 0;
}
} else {
belowRun = 0;
}
}
if (start != null) {
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
}
// Merge close neighbours, then drop slivers.
const merged = [];
for (const seg of segments) {
const last = merged[merged.length - 1];
if (last && seg.startMs - last.endMs <= mergeGapMs) {
last.endMs = seg.endMs;
} else {
merged.push({ ...seg });
}
}
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
}
/** Plain text → the lines worth timing (section labels dropped). */
export function lyricLinesOf(plainText) {
return String(plainText ?? "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0)
.filter((l) => !/^\[.*\]$/.test(l));
}
/** Lay `lines` across `segments` by character weight over the concatenated
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
* inside an active segment), or null when there is nothing to lay onto. */
export function distributeLines(lines, segments) {
if (!lines.length || !segments.length) return null;
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
if (activeTotal < 4000) return null;
const weight = (line) => Math.max(line.length, 8);
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
// virtual position (0..activeTotal) → real ms via the segment list.
const toReal = (virtual) => {
let left = virtual;
for (const seg of segments) {
const len = seg.endMs - seg.startMs;
if (left <= len) return seg.startMs + left;
left -= len;
}
return segments[segments.length - 1].endMs;
};
const out = [];
let cumulative = 0;
for (const line of lines) {
const virtual = (cumulative / totalWeight) * activeTotal;
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
cumulative += weight(line);
}
// Monotonic by construction, but rounding can tie neighbours; nudge.
for (let i = 1; i < out.length; i++) {
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
}
return out;
}
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
export function roughSync(plainText, frames, frameMs) {
const lines = lyricLinesOf(plainText);
const segments = activeSegments(frames ?? [], frameMs ?? 100);
return distributeLines(lines, segments);
}
Binary file not shown.
+69 -3
View File
@@ -227,6 +227,8 @@ export const importStatus = $state({
label: "", // applied: the "· 12 GB" tail; otherwise unused
skipped: 0, // applied: records nothing was written for — the toast must
// say so, because "Imported 0 records" on its own reads as a dead end
error: "", // error: what went wrong, in words. "The import hit a problem"
// is not a reason, and a silent failure is not even that.
});
/** A paused import's presence on the Library page: the Import button wears
@@ -267,6 +269,8 @@ export const ui = $state({
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
windowActive: true,
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
lyricsFull: false,
});
export function toggleNowPlaying() {
@@ -311,16 +315,49 @@ export const libraryStore = $state({
export const playlistStore = $state({
playlists: [],
loaded: false,
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
// mutated playlist (new rows → new mosaic) refetches while an untouched
// one costs nothing on re-render.
covers: {},
});
// revision the cached cover was composed against, and in-flight dedupe.
const playlistCoverRev = new Map();
const playlistCoverInflight = new Map();
export async function refreshPlaylists() {
try {
playlistStore.playlists = await api.listPlaylists();
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
} catch {
// backend not available
} finally {
playlistStore.loaded = true;
}
}
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
/// gradient fallback".
export async function playlistCoverFor(pl, maxEdge = 512) {
const id = pl.id;
const cached = playlistStore.covers[id];
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
const req = api
.playlistCover(id, maxEdge)
.then((url) => {
playlistStore.covers[id] = url ?? null;
playlistCoverRev.set(id, pl.revision);
return url ?? null;
})
.catch(() => null)
.finally(() => playlistCoverInflight.delete(id));
playlistCoverInflight.set(id, req);
return req;
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
@@ -3691,9 +3728,18 @@ export const RESOLVER_AUDIO_QUALITY = {
// Formats that decode progressively while the file is still downloading.
// MP4-family files (m4a/aac) put their index at the end and only play once
// complete, so they're save-only.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
//
// `opus` was here and must not be: this build has no Opus decoder, so the
// resolver would pick an Opus source to stream, download it, and fail at the
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
// see crates/audio-formats.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
// Lossless for LABELLING — what the file claims to be, which is a different
// question from whether we can decode it. `.ape` and `.wv` are genuinely
// lossless and genuinely unplayable here; a peer's file is still worth naming
// accurately in search results.
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
// User source-selection preferences, independent per mode. Loaded from
// settings at startup and refreshed by the Settings view after a commit;
@@ -5031,10 +5077,24 @@ export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? [];
}
// Mirrors crates/audio-formats: everything recognised as audio, playable or
// not. Kept in sync by `audio_format_support`, which the backend answers from
// the same table the scanner and share index use.
export const AUDIO_EXTS = new Set([
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
// Recognised but with no decoder in this build. Shown, searchable, shareable
// and downloadable — never handed to the player, which refuses them anyway.
export const UNPLAYABLE_EXTS = new Set([
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
export const isPlayableExt = (ext) =>
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
export const downloads = $state({
// id -> download payload (latest update wins)
items: {},
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
title: t.title,
sub: subLabel,
artist: t.artist ?? null,
album: t.album ?? null,
duration: t.duration_secs ?? null,
art: t.art_path ?? artPath,
quality: localQuality(t),
// Which playlist OCCURRENCE this queue item came from, so the playlist
// page highlights exactly the row that is playing — a recording that
// appears twice must light up once. Null for album playback.
plItemId: t.plItemId ?? null,
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView,
};
}
+162 -29
View File
@@ -22,6 +22,7 @@
import { tick } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import {
libraryStore,
@@ -94,14 +95,32 @@
// the same join the artist page's own "in your library" ticks already
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
let followsByName = $state({}); // normKey(name) -> { artistId, name }
// artist name -> { total } — persisted so counts survive a restart and the
// shelf head can say "9 of 29" without a fetch. Rows are session-only.
/** "Same artist" for filing purposes, and it is not a new rule: the library
* already groups artist portraits with `GROUP BY artist COLLATE NOCASE`
* (crates/library/src/lib.rs:321), so the shelves fold exactly as wide as
* the backend does. Measured on this library, case alone splits 5 artists
* (King Gizzard "& The"/"& the", Lorn/LORN, Mac DeMarco/Demarco, Boards
* Of/of Canada, My Bloody Valentine) and the stricter punctuation-stripping
* normKey merges nothing beyond those — so the wider key would buy no shelf
* and only risk fusing two artists whose names differ by a mark. */
const foldKey = (s) => (s ?? "").toLowerCase();
// foldKey(artist) -> { total } — persisted so counts survive a restart and
// the shelf head can say "9 of 29" without a fetch. Rows are session-only.
const GAPS_KEY = "library.gaps.v1";
let gapTotals = $state((() => {
try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; }
try {
const saved = JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {};
// Written under the raw shelf name before shelves folded by case,
// so a total banked as "King Gizzard & the Lizard Wizard" is still
// found now the shelf is titled "& The". Folding on load rather
// than at every read keeps one shape in memory.
return Object.fromEntries(Object.entries(saved).map(([k, v]) => [foldKey(k), v]));
} catch { return {}; }
})());
let gapRows = $state({}); // artist name -> catalog rows not owned
let gapOpen = $state({}); // artist name -> line expanded
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -138,14 +157,18 @@
}
async function toggleGaps(artistName) {
if (gapOpen[artistName]) {
gapOpen = { ...gapOpen, [artistName]: false };
// Keyed by the FOLD, not by the shelf's title: the total was the one
// thing still filed under whichever spelling happened to be clicked,
// which is why only one of two King Gizzard shelves read "8 of 42".
const key = foldKey(artistName);
if (gapOpen[key]) {
gapOpen = { ...gapOpen, [key]: false };
return;
}
const follow = followFor(artistName);
if (!follow) return;
if (!gapRows[artistName] && !gapLoading[artistName]) {
gapLoading = { ...gapLoading, [artistName]: true };
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await getCatalogArtistPage(follow.artistId);
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
@@ -156,15 +179,15 @@
);
gapRows = {
...gapRows,
[artistName]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapTotals = { ...gapTotals, [artistName]: { total: released.length } };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
gapLoading = { ...gapLoading, [artistName]: false };
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[artistName]?.length || gapLoading[artistName] === false) {
gapOpen = { ...gapOpen, [artistName]: true };
if (gapRows[key]?.length || gapLoading[key] === false) {
gapOpen = { ...gapOpen, [key]: true };
}
}
@@ -309,7 +332,10 @@
// ---- groupings --------------------------------------------------------
const GROUPS = {
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist" },
// `of` names the shelf, `keyOf` decides which shelf a record lands on.
// They differ for Artist alone: two spellings of one name are one
// artist, so the key folds and shelfName() picks the title.
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist", keyOf: (a) => foldKey(a.artist || "Unknown Artist") },
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
format: {
label: "Format",
@@ -360,12 +386,37 @@
};
function completeness(artist) {
const total = gapTotals[artist]?.total || 0;
const total = gapTotals[foldKey(artist)]?.total || 0;
if (!total) return 1;
const own = rows.filter((a) => a.artist === artist).length;
const own = rows.filter((a) => foldKey(a.artist) === foldKey(artist)).length;
return Math.min(1, own / total);
}
/** The one spelling a folded shelf is filed under.
*
* An anchored follow's own name wins, because that is the catalogue's
* spelling and the same anchor the rest of the page already trusts. With
* no anchor there is nothing authoritative to appeal to, so the records
* vote: the spelling on the most albums, ties broken by sort order so a
* shelf never renames itself between loads.
*
* A shelf with ONE spelling keeps it untouched — including when a follow
* disagrees. Renaming a shelf that was never split is a change nobody
* asked for, and this only exists to reunite the ones that were. */
function shelfName(list) {
const counts = new Map();
for (const a of list) {
const n = a.artist || "Unknown Artist";
counts.set(n, (counts.get(n) || 0) + 1);
}
const spellings = [...counts.keys()];
if (spellings.length === 1) return spellings[0];
const follow = followFor(spellings[0]);
if (follow?.name) return follow.name;
return [...counts.entries()]
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
}
function sortList(list) {
const out = [...list].sort(SORTS[sortKey].cmp);
return desc ? out : out.reverse();
@@ -376,13 +427,25 @@
if (group === "none") return [["", sortList(corpus)]];
const by = new Map();
for (const a of corpus) {
const k = g.of(a);
const k = (g.keyOf ?? g.of)(a);
if (!by.has(k)) by.set(k, []);
by.get(k).push(a);
}
// An artist shelf is keyed by the FOLD, which is not a name, so it takes
// the title shelfName() picks. Every other grouping's key already IS its
// heading. Two folds can still title alike — followFor() joins on the
// coarser normKey, so "AC/DC" and "ACDC" would both take one follow's
// name — and that is the same artist by the app's own rule, so the
// shelves join rather than one silently replacing the other.
const named = new Map();
for (const [k, list] of by) {
const title = group === "artist" ? shelfName(list) : k;
const prev = named.get(title);
if (prev) prev.push(...list); else named.set(title, list);
}
// Added is a DATE grouping: newest month first, by the month the key
// names — a string sort put April 2025 above August 2024.
const keys = [...by.keys()].sort(group === "rtype"
const keys = [...named.keys()].sort(group === "rtype"
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
: group === "artist"
@@ -394,7 +457,7 @@
if (group === "artist" && sortKey === "complete") {
keys.sort((x, y) => completeness(x) - completeness(y));
}
return keys.map((k) => [k, sortList(by.get(k))]);
return keys.map((k) => [k, sortList(named.get(k))]);
});
// Albums, EPs and singles under their own headings INSIDE an artist — the
@@ -414,7 +477,7 @@
// ---- the derived stat row --------------------------------------------
const totals = $derived.by(() => ({
albums: corpus.length,
artists: new Set(corpus.map((a) => a.artist)).size,
artists: new Set(corpus.map((a) => foldKey(a.artist || "Unknown Artist"))).size,
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
secs: corpus.reduce((n, a) => n + a.secs, 0),
bytes: corpus.reduce((n, a) => n + a.bytes, 0),
@@ -1151,6 +1214,25 @@
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
} catch { /* backend unavailable */ }
}
// Disc-aware view of the open tracklist — the artist page's own treatment
// (CatalogArtist's `openDiscs`), so a multi-disc record reads the same
// wherever you meet it: quiet disc rules, and each disc numbering from 1.
//
// `i` is the track's index in `openTracks`, kept alongside because that is
// what playback counts from: grouping the rows must not renumber the queue.
const openDiscs = $derived.by(() => {
const by = new Map();
openTracks.forEach((t, i) => {
const disc = t.disc_no ?? 1;
if (!by.has(disc)) by.set(disc, []);
by.get(disc).push({ t, i });
});
return [...by.entries()]
.sort((a, b) => a[0] - b[0])
.map(([disc, rows]) => ({ disc, rows }));
});
const multiDisc = $derived(openDiscs.length > 1);
const isPlayingTrack = (t) =>
player.track?.source === "local" && player.track?.path === t.path;
function folderOf(tracks) {
@@ -1571,6 +1653,22 @@
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
<Icon name="play" size={12} /> Play
</button>
{#if openTracks.length}
<PlaylistMenu
compact
iconSize={14}
title="Add album to a playlist"
tracks={() =>
openTracks.map((t) => ({
title: t.title,
artist: t.artist,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs,
path: t.path,
art_path: openAlbum?.art_path ?? null,
}))}
/>
{/if}
<button
class="pill ghost"
title="Show in folder"
@@ -1622,7 +1720,11 @@
<span>Title</span>
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{#each openTracks as t, ti (t.id)}
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.rows.length} tracks</div>
{/if}
{#each d.rows as { t, i: ti } (t.id)}
<div
class="trow"
class:now={isPlayingTrack(t)}
@@ -1652,6 +1754,7 @@
onclick={(e) => { e.stopPropagation(); api.showInFolder(t.path); }}
><Icon name="folder-open" size={13} /></button>
</div>
{/each}
{/each}
{:else}
<div class="tracks-loading" role="status">
@@ -1680,12 +1783,13 @@
{#snippet gapSection(name, list)}
{@const follow = followFor(name)}
{#if follow}
{@const missing = gapRows[name]}
{@const total = gapTotals[name]?.total}
{@const key = foldKey(name)}
{@const missing = gapRows[key]}
{@const total = gapTotals[key]?.total}
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
{#if count !== 0}
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
{#if gapLoading[name]}
{#if gapLoading[key]}
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
{:else if count != null}
<b>{count}</b> more released
@@ -1693,12 +1797,12 @@
See what's missing
{/if}
<span class="lib-gapline-cta">
{gapOpen[name] ? "hide" : "show"}
<Icon name={gapOpen[name] ? "chevron-down" : "chevron-right"} size={12} />
{gapOpen[key] ? "hide" : "show"}
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
</span>
</button>
{/if}
{#if gapOpen[name] && missing?.length}
{#if gapOpen[key] && missing?.length}
<!-- Full-size cards, dimmed — the same card as everything else on
the page, readable, full strength on hover (handoff §4d). -->
<div class="lib-gaps" role="list">
@@ -1772,7 +1876,7 @@
{#snippet shelfHead(name, list)}
{@const follow = followFor(name)}
{@const total = group === "artist" ? gapTotals[name]?.total : null}
{@const total = group === "artist" ? gapTotals[foldKey(name)]?.total : null}
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
click resolves one first (openShelfArtist), so an unfollowed shelf is
@@ -3236,6 +3340,15 @@
.pill:hover { background: var(--accent-hover); }
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
.pill.ghost:hover { background: var(--bg-press, #333); }
/* The add-to-playlist trigger wears the ghost-pill look so the CTA row
reads as one family; the component itself stays unstyled chrome. */
.rail-cta :global(.plm-trigger) {
background: var(--bg-highlight);
color: var(--text-primary);
padding: 6px 11px;
border-radius: 14px;
}
.rail-cta :global(.plm-trigger:hover) { background: var(--bg-press, #333); }
.pill.grow { flex: 1; }
.pill:disabled {
background: var(--bg-highlight);
@@ -3261,6 +3374,26 @@
color: var(--text-muted);
}
.tdur-head { display: flex; justify-content: flex-end; }
/* CatalogArtist's `.disc-head`, verbatim — a multi-disc record has to read
the same here as it does on the artist page. */
.disc-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 10px 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.disc-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
.trow {
position: relative;
display: grid;
+1255 -95
View File
File diff suppressed because it is too large. Load diff
+591 -62
View File
@@ -1,132 +1,661 @@
<script>
// The sidebar's Playlists row needs a destination. Playlists used to be
// listed in the sidebar itself; with the sidebar showing listening history
// instead, this is where they live.
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
// The playlist shelf: every playlist as a cover card — custom art or the
// composed four-cover mosaic — with pinning, duplication, export, and the
// two import doors (a playlist file, a pasted tracklist).
import {
playlistStore,
refreshPlaylists,
playlistCoverFor,
goToView,
playAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
let importOpen = $state(false);
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
let probing = $state(false);
$effect(() => {
if (!playlistStore.loaded) refreshPlaylists();
});
// Covers resolve lazily through the store cache; re-render never refetches.
$effect(() => {
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
});
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
if (creating) return;
creating = true;
try {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
} finally {
creating = false;
}
}
async function importFromFile() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportProbe();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("playlist import probe failed", e);
} finally {
probing = false;
}
}
async function importFromFolder() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportFolder();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("folder import failed", e);
} finally {
probing = false;
}
}
function importFromText() {
newMenuOpen = false;
importDraft = null;
importMode = "paste";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
// Hover-play, straight from the card — the Library's own affordance.
// Generation-guarded: a slower fetch from an EARLIER click must not
// override the playlist clicked after it.
let playCardGen = 0;
async function playCard(pl) {
const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return;
const playable = rows.filter((r) => r.path && !r.missing);
if (!playable.length) {
open(pl); // nothing playable: the page explains why
return;
}
playAlbum(
playable.map((r) => ({
path: r.path,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs: r.duration_secs,
art_path: r.art_path,
plItemId: r.id,
})),
pl.name,
0,
);
}
function toggleMenu(e, pl) {
e.stopPropagation();
confirmDelete = null;
menuFor = menuFor === pl.id ? null : pl.id;
}
async function togglePin(pl) {
menuFor = null;
await api.setPlaylistPinned(pl.id, !pl.pinned);
await refreshPlaylists();
}
async function duplicate(pl) {
menuFor = null;
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
}
async function exportAs(pl, format) {
menuFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
console.error("playlist export failed", e);
}
}
async function remove(pl) {
if (confirmDelete !== pl.id) {
confirmDelete = pl.id;
return;
}
menuFor = null;
confirmDelete = null;
await api.deletePlaylist(pl.id);
await refreshPlaylists();
}
function onImported(id, name) {
importOpen = false;
importDraft = null;
refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function fmtDuration(totalSecs) {
if (!totalSecs) return "";
const h = Math.floor(totalSecs / 3600);
const m = Math.round((totalSecs % 3600) / 60);
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
// Close any open menu on a page click.
function pageClick() {
menuFor = null;
newMenuOpen = false;
confirmDelete = null;
}
</script>
<svelte:window onclick={pageClick} />
<div class="page">
<header>
<h1>Playlists</h1>
<button class="new" onclick={newPlaylist}>
<Icon name="plus" size={15} /> New playlist
</button>
<div class="head-text">
<h1>Playlists</h1>
{#if playlistStore.playlists.length}
<p class="sub">
{playlistStore.playlists.length}
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
</p>
{/if}
</div>
<div class="head-actions">
<div class="menu-wrap">
<button
class="btn ghost"
onclick={(e) => {
e.stopPropagation();
menuFor = null;
newMenuOpen = !newMenuOpen;
}}
>
<Icon name="download" size={15} /> Import
<Icon name="chevron-down" size={13} />
</button>
{#if newMenuOpen}
<div class="menu" role="menu">
<button class="mi" onclick={importFromFile} disabled={probing}>
<Icon name="file-text" size={15} />
Playlist file…
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
</button>
<button class="mi" onclick={importFromFolder} disabled={probing}>
<Icon name="folder-open" size={15} />
Folder of audio files…
<span class="hint">tag or natural order</span>
</button>
<button class="mi" onclick={importFromText}>
<Icon name="pencil" size={15} />
Paste a tracklist…
<span class="hint">plain lines or timestamped setlist</span>
</button>
</div>
{/if}
</div>
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button>
</div>
</header>
{#if playlistStore.playlists.length === 0}
<p class="empty">No playlists yet.</p>
<div class="empty">
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
<p class="empty-title">No playlists yet</p>
<p class="empty-sub">
Create one, or import an M3U/XSPF file or a pasted tracklist.
</p>
</div>
{:else}
<ul>
{#each playlistStore.playlists as pl (pl.id)}
<li>
<button class="row" onclick={() => open(pl)}>
<span class="ic"><Icon name="playlist" size={18} /></span>
<span class="name">{pl.name}</span>
<span class="count">
{pl.track_count}
{pl.track_count === 1 ? "track" : "tracks"}
</span>
</button>
</li>
{#if pinned.length}
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
<div class="grid">
{#each pinned as pl (pl.id)}
{@render card(pl)}
{/each}
</div>
{#if rest.length}
<div class="lab all">All playlists</div>
{/if}
{/if}
<div class="grid">
{#each rest as pl (pl.id)}
{@render card(pl)}
{/each}
</ul>
</div>
{/if}
</div>
{#snippet card(pl)}
<div class="card-wrap">
<button class="card" type="button" onclick={() => open(pl)}>
<span class="artwrap">
{#if playlistStore.covers[pl.id]}
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<div class="art fallback">
<Icon name="playlist" size={38} />
</div>
{/if}
{#if pl.track_count > pl.unresolved_count}
<span
class="hoverplay"
role="button"
tabindex="0"
title="Play"
onclick={(e) => {
e.stopPropagation();
playCard(pl);
}}
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
><Icon name="play" size={13} /></span>
{/if}
</span>
<div class="card-title" title={pl.name}>
{pl.name}
</div>
<div class="card-sub">
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
{#if pl.duration_secs}&thinsp;·&thinsp;{fmtDuration(pl.duration_secs)}{/if}
</div>
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
<div class="badges">
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
{#if pl.unresolved_count > 0}
<span class="badge un">{pl.unresolved_count} unresolved</span>
{/if}
</div>
{/if}
</button>
{#if pl.pinned}
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
{/if}
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuFor === pl.id}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="mi" onclick={() => togglePin(pl)}>
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
</button>
<button class="mi" onclick={() => duplicate(pl)}>
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
</button>
</div>
{/if}
</div>
{/snippet}
<PlaylistImportModal
bind:open={importOpen}
mode={importMode}
draft={importDraft}
onimported={onImported}
/>
<style>
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
margin-bottom: 22px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.new {
.sub {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted);
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
padding: 9px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-highlight);
font-weight: 700;
transition:
background 0.14s,
color 0.14s;
color 0.14s,
transform 0.14s;
}
.new:hover {
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
.btn:disabled {
opacity: 0.55;
}
.row {
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 230px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 40;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 13px;
gap: 10px;
width: 100%;
padding: 11px 12px;
padding: 9px 10px;
border-radius: 7px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: background 0.14s;
text-align: left;
white-space: nowrap;
}
.row:hover {
.mi:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.ic {
.mi:disabled {
opacity: 0.5;
}
.mi.danger:hover {
color: #f15e6c;
}
.mi .hint {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
padding-left: 14px;
}
.menu .rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
.lab {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 4px 2px 12px;
}
.lab.all {
margin-top: 26px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
}
.card-wrap {
position: relative;
}
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 13px;
width: 100%;
height: 100%;
transition: background 0.2s;
}
.card:hover {
background: var(--bg-highlight);
}
.artwrap {
position: relative;
display: block;
margin-bottom: 11px;
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
/* The Library card's own hover-play, verbatim in spirit: accent circle
riding the art's corner, revealed by the card hover. */
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 5px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.18s ease,
transform 0.18s ease,
background 0.18s ease;
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
.hoverplay:hover {
background: var(--accent-hover);
}
.card-wrap:hover .hoverplay,
.hoverplay:focus-visible {
opacity: 1;
transform: none;
}
.art.fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
color: var(--text-secondary);
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 12px;
color: var(--text-muted);
flex: none;
.badges {
display: flex;
gap: 5px;
margin-top: 7px;
flex-wrap: wrap;
}
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2.5px 7px;
border-radius: 8px;
}
.badge.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.pinned-mark {
position: absolute;
top: 20px;
left: 20px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(6px);
pointer-events: none;
}
.more {
position: absolute;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition:
opacity 0.15s,
background 0.15s;
}
.card-wrap:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: rgba(0, 0, 0, 0.7);
}
.card-menu {
top: 48px;
right: 14px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 14vh;
text-align: center;
}
.empty-icon {
display: grid;
place-items: center;
width: 92px;
height: 92px;
border-radius: 24px;
background: var(--bg-elevated);
color: var(--text-muted);
margin-bottom: 18px;
}
.empty-title {
font-size: 17px;
font-weight: 700;
}
.empty-sub {
margin-top: 5px;
font-size: 13px;
color: var(--text-muted);
max-width: 380px;
}
</style>