playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
 · <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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user