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

+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;