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
@@ -4,6 +4,8 @@
|
||||
import PlayerBar from "./components/PlayerBar.svelte";
|
||||
import NowPlaying from "./components/NowPlaying.svelte";
|
||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||
import LyricsOverlay from "./components/LyricsOverlay.svelte";
|
||||
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
|
||||
import ImportWindow from "./components/ImportWindow.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||
@@ -126,6 +128,8 @@
|
||||
<FollowToast />
|
||||
<ImportToast />
|
||||
<PreviewOverlay />
|
||||
<LyricsOverlay />
|
||||
<LyricsSyncEditor />
|
||||
<ArtworkViewer />
|
||||
<ImportWindow />
|
||||
<LoginModal />
|
||||
|
||||
@@ -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;
|
||||
|
||||
+111
-8
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
|
||||
export const setSettings = (patch) => invoke("set_settings", patch);
|
||||
export const forgetCredentials = () => invoke("forget_credentials");
|
||||
export const pickFolder = () => invoke("pick_folder");
|
||||
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
|
||||
// Rows are OCCURRENCES with their own ids: the same recording may appear
|
||||
// twice, and every mutation addresses item ids, never positions.
|
||||
export const listPlaylists = () => invoke("list_playlists");
|
||||
export const createPlaylist = (name) => invoke("create_playlist", { name });
|
||||
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
|
||||
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
|
||||
export const setPlaylistDescription = (id, description) =>
|
||||
invoke("set_playlist_description", { id, description: description ?? null });
|
||||
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
|
||||
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
|
||||
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
|
||||
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
|
||||
export const addToPlaylist = (playlistId, tracks) =>
|
||||
invoke("add_to_playlist", { playlistId, tracks });
|
||||
export const removeFromPlaylist = (playlistId, position) =>
|
||||
invoke("remove_from_playlist", { playlistId, position });
|
||||
export const playlistItems = (id) => invoke("playlist_items", { id });
|
||||
export const playlistAddItems = (playlistId, items, at = null) =>
|
||||
invoke("playlist_add_items", { playlistId, items, at });
|
||||
export const playlistRemoveItems = (playlistId, itemIds) =>
|
||||
invoke("playlist_remove_items", { playlistId, itemIds });
|
||||
export const playlistSetOrder = (playlistId, orderedIds) =>
|
||||
invoke("playlist_set_order", { playlistId, orderedIds });
|
||||
// Repair one row: fields null = leave as stored; path non-null relinks the
|
||||
// row to a playable file.
|
||||
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
||||
invoke("playlist_relink_item", {
|
||||
playlistId,
|
||||
itemId,
|
||||
title: fields.title ?? null,
|
||||
artist: fields.artist ?? null,
|
||||
album: fields.album ?? null,
|
||||
durationSecs: fields.durationSecs ?? null,
|
||||
path: fields.path ?? null,
|
||||
artPath: fields.artPath ?? null,
|
||||
});
|
||||
// Cover as a data URL: custom master, else composed mosaic, else null.
|
||||
export const playlistCover = (id, maxEdge = null) =>
|
||||
invoke("playlist_cover", { id, maxEdge });
|
||||
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
|
||||
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
|
||||
// Import: probe opens a file dialog and stages a reviewed draft; text stages
|
||||
// a pasted tracklist; commit writes the accepted draft in one transaction.
|
||||
export const playlistImportProbe = () => invoke("playlist_import_probe");
|
||||
export const playlistImportFolder = () => invoke("playlist_import_folder");
|
||||
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
||||
export const playlistImportCommit = (name, description, kind, entries) =>
|
||||
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
|
||||
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
|
||||
// the written path, or null when the dialog was cancelled.
|
||||
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
|
||||
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
|
||||
export const lyricsGet = (args) =>
|
||||
invoke("lyrics_get", {
|
||||
title: args.title,
|
||||
artist: args.artist,
|
||||
album: args.album ?? null,
|
||||
durationSecs: args.durationSecs ?? null,
|
||||
path: args.path ?? null,
|
||||
refresh: args.refresh ?? null,
|
||||
});
|
||||
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
|
||||
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
|
||||
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
|
||||
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
|
||||
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
|
||||
export const lyricsSaveText = (title, artist, durationSecs, text) =>
|
||||
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
|
||||
export const lyricsClear = (title, artist, durationSecs) =>
|
||||
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
|
||||
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
|
||||
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
|
||||
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
|
||||
// not a learned separator). Resolves to { path, cached } of a playable WAV.
|
||||
export const lyricsPrepareInstrumental = (path) =>
|
||||
invoke("lyrics_prepare_instrumental", { path });
|
||||
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
|
||||
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
|
||||
// Persist hand-synced lines as the track's own document (wins the waterfall).
|
||||
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
|
||||
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
||||
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||
@@ -232,6 +303,11 @@ export const debugStats = () => invoke("debug_stats");
|
||||
// source folder is never written to.
|
||||
export const importScan = (root) => invoke("import_scan", { root });
|
||||
export const importSessionGet = () => invoke("import_session_get");
|
||||
// The source folder relative to the library folder, and back again. Saving
|
||||
// both is what lets a review started on one machine resume on another after
|
||||
// the drive lands at a different letter.
|
||||
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
|
||||
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
|
||||
export const importSessionSet = (state) => invoke("import_session_set", { state });
|
||||
export const importSessionClear = () => invoke("import_session_clear");
|
||||
export const importGetDest = () => invoke("import_get_dest");
|
||||
@@ -239,11 +315,29 @@ export const importSetDest = (dir, layout = null) => invoke("import_set_dest", {
|
||||
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
|
||||
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
||||
// the artwork panel's cover report. Both read-only.
|
||||
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
|
||||
// `title` is the record's own claim (tags); `folder` is the second witness,
|
||||
// asked after it — a record whose tags are wrong still has its folder name.
|
||||
// `shape` is everything ELSE the scan knows — track count, track titles, year,
|
||||
// and where the files are so Rust can hash the record's own cover. That is what
|
||||
// the discography pass compares when no phrase can find the record; the cover
|
||||
// itself never crosses IPC (a master can be 8 MB).
|
||||
export const importIdentify = (artist, title, folder = null, shape = {}) =>
|
||||
invoke("import_identify", {
|
||||
artist, title, folder,
|
||||
tracks: shape.tracks ?? null,
|
||||
trackTitles: shape.trackTitles ?? null,
|
||||
year: shape.year ?? null,
|
||||
sourceRoot: shape.sourceRoot ?? null,
|
||||
rel: shape.rel ?? null,
|
||||
// Bounds the cover hash to the record's own audio — a folded release
|
||||
// gathers from these, an ordinary one from its folder alone.
|
||||
parts: shape.parts ?? null,
|
||||
});
|
||||
export const importCoverReport = (artist, title, referenceUrl) =>
|
||||
invoke("import_cover_report", { artist, title, referenceUrl });
|
||||
// The record's own artwork, measured (embedded first, else folder images).
|
||||
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
|
||||
export const importLocalArt = (sourceRoot, rel, parts) =>
|
||||
invoke("import_local_art", { sourceRoot, rel, parts });
|
||||
// Prune rows whose files are gone, then index anything new — the real rescan.
|
||||
export const rescanLibrary = () => invoke("rescan_library");
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
@@ -252,6 +346,15 @@ export const rescanLibrary = () => invoke("rescan_library");
|
||||
export const importAudition = (username, rel, files, startIndex = 0) =>
|
||||
invoke("import_audition", { username, rel, files, startIndex });
|
||||
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
|
||||
// Which of a found copy's files the pen does not (fully) hold. Transfers die
|
||||
// with the app and the arrived-count dies with them; the files on disk are
|
||||
// what is left to ask.
|
||||
export const importAuditionMissing = (rel, files) =>
|
||||
invoke("import_audition_missing", { rel, files });
|
||||
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
|
||||
// The pen is keyed by RECORD, so without this the dropped peer's files sit
|
||||
// beside the replacement's until the session is cleared.
|
||||
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
|
||||
// Measured facts about one audio file (the version card's quality chips).
|
||||
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
||||
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
// How the import finder asks when it is not ALLOWED to ask.
|
||||
//
|
||||
// The Soulseek server drops a search outright when the query carries every
|
||||
// word of one of its excluded phrases. Measured 2026-08-11 against the live
|
||||
// server, with the record's own copies provably on the network:
|
||||
//
|
||||
// animal collective merriweather 578 peers · 2,664 FLAC files
|
||||
// merriweather pavilion 683 peers
|
||||
// post pavilion 712 peers
|
||||
// merriweather post pavilion 0 peers <- every time
|
||||
// pavilion merriweather post 0 peers <- order is irrelevant
|
||||
// merriweather post pavilion flac 0 peers <- extra terms don't help
|
||||
//
|
||||
// The published exclusions behave identically ("village people" 0, "bryan
|
||||
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
|
||||
// this is that same mechanism — but the server publishes only SOME of its
|
||||
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
|
||||
// unpublished one like this album's title reaches the wire and is swallowed.
|
||||
//
|
||||
// Nothing here can make the server answer a refused query. What it CAN do is
|
||||
// ask a shorter question the server does answer, and then apply the part we
|
||||
// were not allowed to say to the answers, where no server is involved.
|
||||
|
||||
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
|
||||
|
||||
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
|
||||
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
|
||||
* token nothing will match. */
|
||||
export function queryWords(s) {
|
||||
return String(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The record's name with its last word dropped — the shortest change that
|
||||
* breaks the refused word-set while still naming the record.
|
||||
*
|
||||
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
|
||||
* is "the dark side of the", which asks the network for three words that
|
||||
* carry no meaning and narrow the result set for nothing.
|
||||
*
|
||||
* Returns "" when there is nothing left worth asking. A one-word name
|
||||
* shortens to nothing at all, and a bare artist query asks the network for
|
||||
* that artist's entire discography — the same thing `findQueries` refuses
|
||||
* to do. */
|
||||
export function shortenName(name) {
|
||||
const w = queryWords(name);
|
||||
if (w.length < 2) return "";
|
||||
w.pop();
|
||||
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
|
||||
return w.join(" ");
|
||||
}
|
||||
|
||||
/** Does this peer's path claim to BE the record we could not ask for? Every
|
||||
* word of the full name has to appear in it.
|
||||
*
|
||||
* This is the half of the question the server was not allowed to answer, and
|
||||
* it is what keeps the shortened query honest: "animal collective merriweather
|
||||
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
|
||||
* the venue is in the path of every bootleg taped there.
|
||||
*
|
||||
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
|
||||
* a folder that misspells the record is refused rather than guessed at. That
|
||||
* costs a few real copies whose owners cannot spell; accepting near-misses
|
||||
* costs correctness, and there is no way to tell the two apart from here. */
|
||||
export function pathHasTitle(path, name) {
|
||||
const need = queryWords(name);
|
||||
if (!need.length) return false;
|
||||
const have = new Set(queryWords(path));
|
||||
return need.every((w) => have.has(w));
|
||||
}
|
||||
+30
-2
@@ -27,8 +27,36 @@
|
||||
*
|
||||
* 1. Deezer try-list, MusicBrainz fallback.
|
||||
* 2. + the artist's own catalogue, ranked by title similarity — finds records
|
||||
* whose folder name no release carries ("1989 Pauls Boutique"). */
|
||||
export const IDENTIFY_ENGINE = 2;
|
||||
* whose folder name no release carries ("1989 Pauls Boutique").
|
||||
* 3. + the artist the TAGS name, asked when the folder's artist found nothing
|
||||
* or the wrong record, and + a try-list that refuses to strip a title down
|
||||
* to its catalogue number ("Band Of Gypsys (SICP 2643)" → "(SICP 2643)").
|
||||
* 4. + the title without a trailing release-type word ("Willoughby's Beach EP"
|
||||
* → "Willoughby's Beach"). Both providers require every word of the
|
||||
* phrase, so the one appended word hid a record each of them holds.
|
||||
* 5. + the FOLDER as a second witness, asked after the record's own claim.
|
||||
* Tags carry typos the folder does not ("Nonagon Infiniry" on all nine
|
||||
* files, in a correctly-spelled folder), and until now one wrong tag was
|
||||
* the only thing any provider was ever asked about.
|
||||
* 6. + the record's TRACK COUNT breaks the tie when one title names several
|
||||
* MusicBrainz release groups ("Murder of the Universe" is a 21-track Album
|
||||
* and a 1-track Single, both scored 100, in an unstable order), and a
|
||||
* provider request that FAILED no longer caches as "nothing found".
|
||||
* 7. The last resort stopped being a title search dressed as a comparison. It
|
||||
* ranks the artist's WHOLE MusicBrainz discography — complete, where
|
||||
* Deezer's listing omitted EPs entirely — and decides on every fact the
|
||||
* scan has: wording, track count, year, the TRACKLIST, and the record's own
|
||||
* ARTWORK hashed against the discography's covers. A name is one fact about
|
||||
* a record and the one most often wrong; `Rock Bottom 7"` scores 0.667
|
||||
* against the release actually called "Rock Bottom / Octopus", and 2 of 2
|
||||
* on its songs.
|
||||
*
|
||||
* BUMP THIS AFTER THE RUST BUILD LANDS, never before. Vite hot-reloads this
|
||||
* constant instantly while the capability it describes lives in
|
||||
* `metadata::identify_titles`, so bumping it against a running old binary
|
||||
* makes that binary sweep the negatives and re-stamp the record on screen as
|
||||
* freshly looked at by code that learned nothing. */
|
||||
export const IDENTIFY_ENGINE = 7;
|
||||
|
||||
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
|
||||
* value seen, which keeps the result stable for a re-render. */
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
|
||||
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
|
||||
// editor. Never presented as finished timing.
|
||||
//
|
||||
// The method deliberately stays above the research doc's forbidden floor
|
||||
// ("never estimate line positions by evenly distributing text across song
|
||||
// duration"): lines are laid out only across MEASURED vocal-active segments,
|
||||
// weighted by line length, and when the measurement shows nothing usable the
|
||||
// generator refuses instead of guessing. Real generated timing (ASR anchors
|
||||
// + forced alignment, per the research) is a later, model-gated feature.
|
||||
//
|
||||
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
|
||||
|
||||
/** Hysteresis segmentation of a 0..1 activity envelope.
|
||||
* Returns [{ startMs, endMs }], merged and pruned. */
|
||||
export function activeSegments(frames, frameMs, opts = {}) {
|
||||
const on = opts.on ?? 0.32; // enter singing
|
||||
const off = opts.off ?? 0.16; // leave singing…
|
||||
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
|
||||
const minSegMs = opts.minSegMs ?? 1200;
|
||||
const mergeGapMs = opts.mergeGapMs ?? 700;
|
||||
|
||||
const segments = [];
|
||||
let start = null;
|
||||
let belowRun = 0;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const v = frames[i];
|
||||
if (start == null) {
|
||||
if (v >= on) {
|
||||
start = i;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else if (v < off) {
|
||||
belowRun += frameMs;
|
||||
if (belowRun >= offRunMs) {
|
||||
const end = i * frameMs - belowRun + frameMs;
|
||||
segments.push({ startMs: start * frameMs, endMs: end });
|
||||
start = null;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else {
|
||||
belowRun = 0;
|
||||
}
|
||||
}
|
||||
if (start != null) {
|
||||
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
|
||||
}
|
||||
// Merge close neighbours, then drop slivers.
|
||||
const merged = [];
|
||||
for (const seg of segments) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && seg.startMs - last.endMs <= mergeGapMs) {
|
||||
last.endMs = seg.endMs;
|
||||
} else {
|
||||
merged.push({ ...seg });
|
||||
}
|
||||
}
|
||||
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
|
||||
}
|
||||
|
||||
/** Plain text → the lines worth timing (section labels dropped). */
|
||||
export function lyricLinesOf(plainText) {
|
||||
return String(plainText ?? "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.filter((l) => !/^\[.*\]$/.test(l));
|
||||
}
|
||||
|
||||
/** Lay `lines` across `segments` by character weight over the concatenated
|
||||
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
|
||||
* inside an active segment), or null when there is nothing to lay onto. */
|
||||
export function distributeLines(lines, segments) {
|
||||
if (!lines.length || !segments.length) return null;
|
||||
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
|
||||
if (activeTotal < 4000) return null;
|
||||
const weight = (line) => Math.max(line.length, 8);
|
||||
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
|
||||
// virtual position (0..activeTotal) → real ms via the segment list.
|
||||
const toReal = (virtual) => {
|
||||
let left = virtual;
|
||||
for (const seg of segments) {
|
||||
const len = seg.endMs - seg.startMs;
|
||||
if (left <= len) return seg.startMs + left;
|
||||
left -= len;
|
||||
}
|
||||
return segments[segments.length - 1].endMs;
|
||||
};
|
||||
const out = [];
|
||||
let cumulative = 0;
|
||||
for (const line of lines) {
|
||||
const virtual = (cumulative / totalWeight) * activeTotal;
|
||||
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
|
||||
cumulative += weight(line);
|
||||
}
|
||||
// Monotonic by construction, but rounding can tie neighbours; nudge.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
|
||||
export function roughSync(plainText, frames, frameMs) {
|
||||
const lines = lyricLinesOf(plainText);
|
||||
const segments = activeSegments(frames ?? [], frameMs ?? 100);
|
||||
return distributeLines(lines, segments);
|
||||
}
|
||||
Binary file not shown.
+69
-3
@@ -227,6 +227,8 @@ export const importStatus = $state({
|
||||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||||
skipped: 0, // applied: records nothing was written for — the toast must
|
||||
// say so, because "Imported 0 records" on its own reads as a dead end
|
||||
error: "", // error: what went wrong, in words. "The import hit a problem"
|
||||
// is not a reason, and a silent failure is not even that.
|
||||
});
|
||||
|
||||
/** A paused import's presence on the Library page: the Import button wears
|
||||
@@ -267,6 +269,8 @@ export const ui = $state({
|
||||
// window is minimized. This is the authoritative shared activity gate for
|
||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||
windowActive: true,
|
||||
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
|
||||
lyricsFull: false,
|
||||
});
|
||||
|
||||
export function toggleNowPlaying() {
|
||||
@@ -311,16 +315,49 @@ export const libraryStore = $state({
|
||||
|
||||
export const playlistStore = $state({
|
||||
playlists: [],
|
||||
loaded: false,
|
||||
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
|
||||
// mutated playlist (new rows → new mosaic) refetches while an untouched
|
||||
// one costs nothing on re-render.
|
||||
covers: {},
|
||||
});
|
||||
|
||||
// revision the cached cover was composed against, and in-flight dedupe.
|
||||
const playlistCoverRev = new Map();
|
||||
const playlistCoverInflight = new Map();
|
||||
|
||||
export async function refreshPlaylists() {
|
||||
try {
|
||||
playlistStore.playlists = await api.listPlaylists();
|
||||
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||||
} catch {
|
||||
// backend not available
|
||||
} finally {
|
||||
playlistStore.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
|
||||
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
|
||||
/// gradient fallback".
|
||||
export async function playlistCoverFor(pl, maxEdge = 512) {
|
||||
const id = pl.id;
|
||||
const cached = playlistStore.covers[id];
|
||||
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
|
||||
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
|
||||
const req = api
|
||||
.playlistCover(id, maxEdge)
|
||||
.then((url) => {
|
||||
playlistStore.covers[id] = url ?? null;
|
||||
playlistCoverRev.set(id, pl.revision);
|
||||
return url ?? null;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => playlistCoverInflight.delete(id));
|
||||
playlistCoverInflight.set(id, req);
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- recently played (the sidebar's list) ----
|
||||
// A play row is opened after ~1s of audio, so without a floor this list is a
|
||||
// log of skips. 45s is the floor the live table was measured against.
|
||||
@@ -3691,9 +3728,18 @@ export const RESOLVER_AUDIO_QUALITY = {
|
||||
// Formats that decode progressively while the file is still downloading.
|
||||
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||||
// complete, so they're save-only.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
|
||||
//
|
||||
// `opus` was here and must not be: this build has no Opus decoder, so the
|
||||
// resolver would pick an Opus source to stream, download it, and fail at the
|
||||
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
|
||||
// see crates/audio-formats.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
|
||||
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
|
||||
// Lossless for LABELLING — what the file claims to be, which is a different
|
||||
// question from whether we can decode it. `.ape` and `.wv` are genuinely
|
||||
// lossless and genuinely unplayable here; a peer's file is still worth naming
|
||||
// accurately in search results.
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
|
||||
|
||||
// User source-selection preferences, independent per mode. Loaded from
|
||||
// settings at startup and refreshed by the Settings view after a commit;
|
||||
@@ -5031,10 +5077,24 @@ export function filesForGroup(key) {
|
||||
return searchFiles.get(key)?.files ?? [];
|
||||
}
|
||||
|
||||
// Mirrors crates/audio-formats: everything recognised as audio, playable or
|
||||
// not. Kept in sync by `audio_format_support`, which the backend answers from
|
||||
// the same table the scanner and share index use.
|
||||
export const AUDIO_EXTS = new Set([
|
||||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
||||
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
|
||||
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
|
||||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||||
]);
|
||||
|
||||
// Recognised but with no decoder in this build. Shown, searchable, shareable
|
||||
// and downloadable — never handed to the player, which refuses them anyway.
|
||||
export const UNPLAYABLE_EXTS = new Set([
|
||||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||||
]);
|
||||
export const isPlayableExt = (ext) =>
|
||||
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
|
||||
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
|
||||
|
||||
export const downloads = $state({
|
||||
// id -> download payload (latest update wins)
|
||||
items: {},
|
||||
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
||||
title: t.title,
|
||||
sub: subLabel,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration: t.duration_secs ?? null,
|
||||
art: t.art_path ?? artPath,
|
||||
quality: localQuality(t),
|
||||
// Which playlist OCCURRENCE this queue item came from, so the playlist
|
||||
// page highlights exactly the row that is playing — a recording that
|
||||
// appears twice must light up once. Null for album playback.
|
||||
plItemId: t.plItemId ?? null,
|
||||
}));
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
playQueueIndex(start);
|
||||
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
|
||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||
catalogGo, refreshLibrary, refreshRecents,
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
playlistStore, refreshPlaylists, goToView,
|
||||
};
|
||||
}
|
||||
+162
-29
@@ -22,6 +22,7 @@
|
||||
import { tick } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
libraryStore,
|
||||
@@ -94,14 +95,32 @@
|
||||
// the same join the artist page's own "in your library" ticks already
|
||||
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
|
||||
let followsByName = $state({}); // normKey(name) -> { artistId, name }
|
||||
// artist name -> { total } — persisted so counts survive a restart and the
|
||||
// shelf head can say "9 of 29" without a fetch. Rows are session-only.
|
||||
|
||||
/** "Same artist" for filing purposes, and it is not a new rule: the library
|
||||
* already groups artist portraits with `GROUP BY artist COLLATE NOCASE`
|
||||
* (crates/library/src/lib.rs:321), so the shelves fold exactly as wide as
|
||||
* the backend does. Measured on this library, case alone splits 5 artists
|
||||
* (King Gizzard "& The"/"& the", Lorn/LORN, Mac DeMarco/Demarco, Boards
|
||||
* Of/of Canada, My Bloody Valentine) and the stricter punctuation-stripping
|
||||
* normKey merges nothing beyond those — so the wider key would buy no shelf
|
||||
* and only risk fusing two artists whose names differ by a mark. */
|
||||
const foldKey = (s) => (s ?? "").toLowerCase();
|
||||
|
||||
// foldKey(artist) -> { total } — persisted so counts survive a restart and
|
||||
// the shelf head can say "9 of 29" without a fetch. Rows are session-only.
|
||||
const GAPS_KEY = "library.gaps.v1";
|
||||
let gapTotals = $state((() => {
|
||||
try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; }
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {};
|
||||
// Written under the raw shelf name before shelves folded by case,
|
||||
// so a total banked as "King Gizzard & the Lizard Wizard" is still
|
||||
// found now the shelf is titled "& The". Folding on load rather
|
||||
// than at every read keeps one shape in memory.
|
||||
return Object.fromEntries(Object.entries(saved).map(([k, v]) => [foldKey(k), v]));
|
||||
} catch { return {}; }
|
||||
})());
|
||||
let gapRows = $state({}); // artist name -> catalog rows not owned
|
||||
let gapOpen = $state({}); // artist name -> line expanded
|
||||
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
|
||||
let gapOpen = $state({}); // foldKey(artist) -> line expanded
|
||||
let gapLoading = $state({});
|
||||
|
||||
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
@@ -138,14 +157,18 @@
|
||||
}
|
||||
|
||||
async function toggleGaps(artistName) {
|
||||
if (gapOpen[artistName]) {
|
||||
gapOpen = { ...gapOpen, [artistName]: false };
|
||||
// Keyed by the FOLD, not by the shelf's title: the total was the one
|
||||
// thing still filed under whichever spelling happened to be clicked,
|
||||
// which is why only one of two King Gizzard shelves read "8 of 42".
|
||||
const key = foldKey(artistName);
|
||||
if (gapOpen[key]) {
|
||||
gapOpen = { ...gapOpen, [key]: false };
|
||||
return;
|
||||
}
|
||||
const follow = followFor(artistName);
|
||||
if (!follow) return;
|
||||
if (!gapRows[artistName] && !gapLoading[artistName]) {
|
||||
gapLoading = { ...gapLoading, [artistName]: true };
|
||||
if (!gapRows[key] && !gapLoading[key]) {
|
||||
gapLoading = { ...gapLoading, [key]: true };
|
||||
try {
|
||||
const page = await getCatalogArtistPage(follow.artistId);
|
||||
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
|
||||
@@ -156,15 +179,15 @@
|
||||
);
|
||||
gapRows = {
|
||||
...gapRows,
|
||||
[artistName]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
|
||||
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
|
||||
};
|
||||
gapTotals = { ...gapTotals, [artistName]: { total: released.length } };
|
||||
gapTotals = { ...gapTotals, [key]: { total: released.length } };
|
||||
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
|
||||
} catch { /* the line stays collapsed; nothing is invented */ }
|
||||
gapLoading = { ...gapLoading, [artistName]: false };
|
||||
gapLoading = { ...gapLoading, [key]: false };
|
||||
}
|
||||
if (gapRows[artistName]?.length || gapLoading[artistName] === false) {
|
||||
gapOpen = { ...gapOpen, [artistName]: true };
|
||||
if (gapRows[key]?.length || gapLoading[key] === false) {
|
||||
gapOpen = { ...gapOpen, [key]: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +332,10 @@
|
||||
|
||||
// ---- groupings --------------------------------------------------------
|
||||
const GROUPS = {
|
||||
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist" },
|
||||
// `of` names the shelf, `keyOf` decides which shelf a record lands on.
|
||||
// They differ for Artist alone: two spellings of one name are one
|
||||
// artist, so the key folds and shelfName() picks the title.
|
||||
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist", keyOf: (a) => foldKey(a.artist || "Unknown Artist") },
|
||||
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
|
||||
format: {
|
||||
label: "Format",
|
||||
@@ -360,12 +386,37 @@
|
||||
};
|
||||
|
||||
function completeness(artist) {
|
||||
const total = gapTotals[artist]?.total || 0;
|
||||
const total = gapTotals[foldKey(artist)]?.total || 0;
|
||||
if (!total) return 1;
|
||||
const own = rows.filter((a) => a.artist === artist).length;
|
||||
const own = rows.filter((a) => foldKey(a.artist) === foldKey(artist)).length;
|
||||
return Math.min(1, own / total);
|
||||
}
|
||||
|
||||
/** The one spelling a folded shelf is filed under.
|
||||
*
|
||||
* An anchored follow's own name wins, because that is the catalogue's
|
||||
* spelling and the same anchor the rest of the page already trusts. With
|
||||
* no anchor there is nothing authoritative to appeal to, so the records
|
||||
* vote: the spelling on the most albums, ties broken by sort order so a
|
||||
* shelf never renames itself between loads.
|
||||
*
|
||||
* A shelf with ONE spelling keeps it untouched — including when a follow
|
||||
* disagrees. Renaming a shelf that was never split is a change nobody
|
||||
* asked for, and this only exists to reunite the ones that were. */
|
||||
function shelfName(list) {
|
||||
const counts = new Map();
|
||||
for (const a of list) {
|
||||
const n = a.artist || "Unknown Artist";
|
||||
counts.set(n, (counts.get(n) || 0) + 1);
|
||||
}
|
||||
const spellings = [...counts.keys()];
|
||||
if (spellings.length === 1) return spellings[0];
|
||||
const follow = followFor(spellings[0]);
|
||||
if (follow?.name) return follow.name;
|
||||
return [...counts.entries()]
|
||||
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
|
||||
}
|
||||
|
||||
function sortList(list) {
|
||||
const out = [...list].sort(SORTS[sortKey].cmp);
|
||||
return desc ? out : out.reverse();
|
||||
@@ -376,13 +427,25 @@
|
||||
if (group === "none") return [["", sortList(corpus)]];
|
||||
const by = new Map();
|
||||
for (const a of corpus) {
|
||||
const k = g.of(a);
|
||||
const k = (g.keyOf ?? g.of)(a);
|
||||
if (!by.has(k)) by.set(k, []);
|
||||
by.get(k).push(a);
|
||||
}
|
||||
// An artist shelf is keyed by the FOLD, which is not a name, so it takes
|
||||
// the title shelfName() picks. Every other grouping's key already IS its
|
||||
// heading. Two folds can still title alike — followFor() joins on the
|
||||
// coarser normKey, so "AC/DC" and "ACDC" would both take one follow's
|
||||
// name — and that is the same artist by the app's own rule, so the
|
||||
// shelves join rather than one silently replacing the other.
|
||||
const named = new Map();
|
||||
for (const [k, list] of by) {
|
||||
const title = group === "artist" ? shelfName(list) : k;
|
||||
const prev = named.get(title);
|
||||
if (prev) prev.push(...list); else named.set(title, list);
|
||||
}
|
||||
// Added is a DATE grouping: newest month first, by the month the key
|
||||
// names — a string sort put April 2025 above August 2024.
|
||||
const keys = [...by.keys()].sort(group === "rtype"
|
||||
const keys = [...named.keys()].sort(group === "rtype"
|
||||
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
|
||||
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
|
||||
: group === "artist"
|
||||
@@ -394,7 +457,7 @@
|
||||
if (group === "artist" && sortKey === "complete") {
|
||||
keys.sort((x, y) => completeness(x) - completeness(y));
|
||||
}
|
||||
return keys.map((k) => [k, sortList(by.get(k))]);
|
||||
return keys.map((k) => [k, sortList(named.get(k))]);
|
||||
});
|
||||
|
||||
// Albums, EPs and singles under their own headings INSIDE an artist — the
|
||||
@@ -414,7 +477,7 @@
|
||||
// ---- the derived stat row --------------------------------------------
|
||||
const totals = $derived.by(() => ({
|
||||
albums: corpus.length,
|
||||
artists: new Set(corpus.map((a) => a.artist)).size,
|
||||
artists: new Set(corpus.map((a) => foldKey(a.artist || "Unknown Artist"))).size,
|
||||
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
|
||||
secs: corpus.reduce((n, a) => n + a.secs, 0),
|
||||
bytes: corpus.reduce((n, a) => n + a.bytes, 0),
|
||||
@@ -1151,6 +1214,25 @@
|
||||
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
|
||||
} catch { /* backend unavailable */ }
|
||||
}
|
||||
// Disc-aware view of the open tracklist — the artist page's own treatment
|
||||
// (CatalogArtist's `openDiscs`), so a multi-disc record reads the same
|
||||
// wherever you meet it: quiet disc rules, and each disc numbering from 1.
|
||||
//
|
||||
// `i` is the track's index in `openTracks`, kept alongside because that is
|
||||
// what playback counts from: grouping the rows must not renumber the queue.
|
||||
const openDiscs = $derived.by(() => {
|
||||
const by = new Map();
|
||||
openTracks.forEach((t, i) => {
|
||||
const disc = t.disc_no ?? 1;
|
||||
if (!by.has(disc)) by.set(disc, []);
|
||||
by.get(disc).push({ t, i });
|
||||
});
|
||||
return [...by.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([disc, rows]) => ({ disc, rows }));
|
||||
});
|
||||
const multiDisc = $derived(openDiscs.length > 1);
|
||||
|
||||
const isPlayingTrack = (t) =>
|
||||
player.track?.source === "local" && player.track?.path === t.path;
|
||||
function folderOf(tracks) {
|
||||
@@ -1571,6 +1653,22 @@
|
||||
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
|
||||
<Icon name="play" size={12} /> Play
|
||||
</button>
|
||||
{#if openTracks.length}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={14}
|
||||
title="Add album to a playlist"
|
||||
tracks={() =>
|
||||
openTracks.map((t) => ({
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: openAlbum?.title ?? null,
|
||||
duration_secs: t.duration_secs,
|
||||
path: t.path,
|
||||
art_path: openAlbum?.art_path ?? null,
|
||||
}))}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="pill ghost"
|
||||
title="Show in folder"
|
||||
@@ -1622,7 +1720,11 @@
|
||||
<span>Title</span>
|
||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{#each openTracks as t, ti (t.id)}
|
||||
{#each openDiscs as d (d.disc)}
|
||||
{#if multiDisc}
|
||||
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.rows.length} tracks</div>
|
||||
{/if}
|
||||
{#each d.rows as { t, i: ti } (t.id)}
|
||||
<div
|
||||
class="trow"
|
||||
class:now={isPlayingTrack(t)}
|
||||
@@ -1652,6 +1754,7 @@
|
||||
onclick={(e) => { e.stopPropagation(); api.showInFolder(t.path); }}
|
||||
><Icon name="folder-open" size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="tracks-loading" role="status">
|
||||
@@ -1680,12 +1783,13 @@
|
||||
{#snippet gapSection(name, list)}
|
||||
{@const follow = followFor(name)}
|
||||
{#if follow}
|
||||
{@const missing = gapRows[name]}
|
||||
{@const total = gapTotals[name]?.total}
|
||||
{@const key = foldKey(name)}
|
||||
{@const missing = gapRows[key]}
|
||||
{@const total = gapTotals[key]?.total}
|
||||
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
|
||||
{#if count !== 0}
|
||||
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
|
||||
{#if gapLoading[name]}
|
||||
{#if gapLoading[key]}
|
||||
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
|
||||
{:else if count != null}
|
||||
<b>{count}</b> more released
|
||||
@@ -1693,12 +1797,12 @@
|
||||
See what's missing
|
||||
{/if}
|
||||
<span class="lib-gapline-cta">
|
||||
{gapOpen[name] ? "hide" : "show"}
|
||||
<Icon name={gapOpen[name] ? "chevron-down" : "chevron-right"} size={12} />
|
||||
{gapOpen[key] ? "hide" : "show"}
|
||||
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if gapOpen[name] && missing?.length}
|
||||
{#if gapOpen[key] && missing?.length}
|
||||
<!-- Full-size cards, dimmed — the same card as everything else on
|
||||
the page, readable, full strength on hover (handoff §4d). -->
|
||||
<div class="lib-gaps" role="list">
|
||||
@@ -1772,7 +1876,7 @@
|
||||
|
||||
{#snippet shelfHead(name, list)}
|
||||
{@const follow = followFor(name)}
|
||||
{@const total = group === "artist" ? gapTotals[name]?.total : null}
|
||||
{@const total = group === "artist" ? gapTotals[foldKey(name)]?.total : null}
|
||||
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
|
||||
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
|
||||
click resolves one first (openShelfArtist), so an unfollowed shelf is
|
||||
@@ -3236,6 +3340,15 @@
|
||||
.pill:hover { background: var(--accent-hover); }
|
||||
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
|
||||
.pill.ghost:hover { background: var(--bg-press, #333); }
|
||||
/* The add-to-playlist trigger wears the ghost-pill look so the CTA row
|
||||
reads as one family; the component itself stays unstyled chrome. */
|
||||
.rail-cta :global(.plm-trigger) {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 11px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.rail-cta :global(.plm-trigger:hover) { background: var(--bg-press, #333); }
|
||||
.pill.grow { flex: 1; }
|
||||
.pill:disabled {
|
||||
background: var(--bg-highlight);
|
||||
@@ -3261,6 +3374,26 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tdur-head { display: flex; justify-content: flex-end; }
|
||||
/* CatalogArtist's `.disc-head`, verbatim — a multi-disc record has to read
|
||||
the same here as it does on the artist page. */
|
||||
.disc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 10px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.disc-head::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.trow {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
+1255
-95
File diff suppressed because it is too large.
Load diff
+591
-62
@@ -1,132 +1,661 @@
|
||||
<script>
|
||||
// The sidebar's Playlists row needs a destination. Playlists used to be
|
||||
// listed in the sidebar itself; with the sidebar showing listening history
|
||||
// instead, this is where they live.
|
||||
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
|
||||
// The playlist shelf: every playlist as a cover card — custom art or the
|
||||
// composed four-cover mosaic — with pinning, duplication, export, and the
|
||||
// two import doors (a playlist file, a pasted tracklist).
|
||||
import {
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
playlistCoverFor,
|
||||
goToView,
|
||||
playAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
|
||||
|
||||
let importOpen = $state(false);
|
||||
let importMode = $state("file"); // "file" | "paste"
|
||||
let importDraft = $state(null);
|
||||
let menuFor = $state(null); // playlist id whose ⋯ menu is open
|
||||
let confirmDelete = $state(null); // playlist id armed for deletion
|
||||
let creating = $state(false);
|
||||
let newMenuOpen = $state(false);
|
||||
let probing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!playlistStore.loaded) refreshPlaylists();
|
||||
});
|
||||
|
||||
// Covers resolve lazily through the store cache; re-render never refetches.
|
||||
$effect(() => {
|
||||
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||||
});
|
||||
|
||||
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
|
||||
|
||||
async function newPlaylist() {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
try {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFile() {
|
||||
newMenuOpen = false;
|
||||
if (probing) return;
|
||||
probing = true;
|
||||
try {
|
||||
const draft = await api.playlistImportProbe();
|
||||
if (draft) {
|
||||
importDraft = draft;
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("playlist import probe failed", e);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFolder() {
|
||||
newMenuOpen = false;
|
||||
if (probing) return;
|
||||
probing = true;
|
||||
try {
|
||||
const draft = await api.playlistImportFolder();
|
||||
if (draft) {
|
||||
importDraft = draft;
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("folder import failed", e);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function importFromText() {
|
||||
newMenuOpen = false;
|
||||
importDraft = null;
|
||||
importMode = "paste";
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
|
||||
// Hover-play, straight from the card — the Library's own affordance.
|
||||
// Generation-guarded: a slower fetch from an EARLIER click must not
|
||||
// override the playlist clicked after it.
|
||||
let playCardGen = 0;
|
||||
async function playCard(pl) {
|
||||
const gen = ++playCardGen;
|
||||
const rows = await api.playlistItems(pl.id);
|
||||
if (gen !== playCardGen) return;
|
||||
const playable = rows.filter((r) => r.path && !r.missing);
|
||||
if (!playable.length) {
|
||||
open(pl); // nothing playable: the page explains why
|
||||
return;
|
||||
}
|
||||
playAlbum(
|
||||
playable.map((r) => ({
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs: r.duration_secs,
|
||||
art_path: r.art_path,
|
||||
plItemId: r.id,
|
||||
})),
|
||||
pl.name,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function toggleMenu(e, pl) {
|
||||
e.stopPropagation();
|
||||
confirmDelete = null;
|
||||
menuFor = menuFor === pl.id ? null : pl.id;
|
||||
}
|
||||
|
||||
async function togglePin(pl) {
|
||||
menuFor = null;
|
||||
await api.setPlaylistPinned(pl.id, !pl.pinned);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
async function duplicate(pl) {
|
||||
menuFor = null;
|
||||
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
|
||||
}
|
||||
|
||||
async function exportAs(pl, format) {
|
||||
menuFor = null;
|
||||
try {
|
||||
await api.playlistExport(pl.id, format);
|
||||
} catch (e) {
|
||||
console.error("playlist export failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(pl) {
|
||||
if (confirmDelete !== pl.id) {
|
||||
confirmDelete = pl.id;
|
||||
return;
|
||||
}
|
||||
menuFor = null;
|
||||
confirmDelete = null;
|
||||
await api.deletePlaylist(pl.id);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
function onImported(id, name) {
|
||||
importOpen = false;
|
||||
importDraft = null;
|
||||
refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
}
|
||||
|
||||
function fmtDuration(totalSecs) {
|
||||
if (!totalSecs) return "";
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
const m = Math.round((totalSecs % 3600) / 60);
|
||||
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
|
||||
}
|
||||
|
||||
// Close any open menu on a page click.
|
||||
function pageClick() {
|
||||
menuFor = null;
|
||||
newMenuOpen = false;
|
||||
confirmDelete = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={pageClick} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Playlists</h1>
|
||||
<button class="new" onclick={newPlaylist}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
<div class="head-text">
|
||||
<h1>Playlists</h1>
|
||||
{#if playlistStore.playlists.length}
|
||||
<p class="sub">
|
||||
{playlistStore.playlists.length}
|
||||
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="btn ghost"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuFor = null;
|
||||
newMenuOpen = !newMenuOpen;
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={15} /> Import
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{#if newMenuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button class="mi" onclick={importFromFile} disabled={probing}>
|
||||
<Icon name="file-text" size={15} />
|
||||
Playlist file…
|
||||
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromFolder} disabled={probing}>
|
||||
<Icon name="folder-open" size={15} />
|
||||
Folder of audio files…
|
||||
<span class="hint">tag or natural order</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromText}>
|
||||
<Icon name="pencil" size={15} />
|
||||
Paste a tracklist…
|
||||
<span class="hint">plain lines or timestamped setlist</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
<p class="empty">No playlists yet.</p>
|
||||
<div class="empty">
|
||||
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
|
||||
<p class="empty-title">No playlists yet</p>
|
||||
<p class="empty-sub">
|
||||
Create one, or import an M3U/XSPF file or a pasted tracklist.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<li>
|
||||
<button class="row" onclick={() => open(pl)}>
|
||||
<span class="ic"><Icon name="playlist" size={18} /></span>
|
||||
<span class="name">{pl.name}</span>
|
||||
<span class="count">
|
||||
{pl.track_count}
|
||||
{pl.track_count === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{#if pinned.length}
|
||||
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
|
||||
<div class="grid">
|
||||
{#each pinned as pl (pl.id)}
|
||||
{@render card(pl)}
|
||||
{/each}
|
||||
</div>
|
||||
{#if rest.length}
|
||||
<div class="lab all">All playlists</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="grid">
|
||||
{#each rest as pl (pl.id)}
|
||||
{@render card(pl)}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet card(pl)}
|
||||
<div class="card-wrap">
|
||||
<button class="card" type="button" onclick={() => open(pl)}>
|
||||
<span class="artwrap">
|
||||
{#if playlistStore.covers[pl.id]}
|
||||
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="art fallback">
|
||||
<Icon name="playlist" size={38} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if pl.track_count > pl.unresolved_count}
|
||||
<span
|
||||
class="hoverplay"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Play"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
playCard(pl);
|
||||
}}
|
||||
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
|
||||
><Icon name="play" size={13} /></span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="card-title" title={pl.name}>
|
||||
{pl.name}
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
|
||||
{#if pl.duration_secs} · {fmtDuration(pl.duration_secs)}{/if}
|
||||
</div>
|
||||
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
|
||||
<div class="badges">
|
||||
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
|
||||
{#if pl.unresolved_count > 0}
|
||||
<span class="badge un">{pl.unresolved_count} unresolved</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if pl.pinned}
|
||||
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
|
||||
{/if}
|
||||
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
|
||||
<Icon name="more-horizontal" size={17} />
|
||||
</button>
|
||||
{#if menuFor === pl.id}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="mi" onclick={() => togglePin(pl)}>
|
||||
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
<button class="mi" onclick={() => duplicate(pl)}>
|
||||
<Icon name="layers" size={15} /> Duplicate
|
||||
</button>
|
||||
<div class="rule"></div>
|
||||
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
|
||||
<Icon name="export" size={15} /> Export M3U8
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
|
||||
<Icon name="export" size={15} /> Export XSPF
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "pls")}>
|
||||
<Icon name="export" size={15} /> Export PLS
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "csv")}>
|
||||
<Icon name="export" size={15} /> Export CSV
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "json")}>
|
||||
<Icon name="export" size={15} /> Export backup (JSON)
|
||||
</button>
|
||||
<div class="rule"></div>
|
||||
<button class="mi danger" onclick={() => remove(pl)}>
|
||||
<Icon name="trash" size={15} />
|
||||
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<PlaylistImportModal
|
||||
bind:open={importOpen}
|
||||
mode={importMode}
|
||||
draft={importDraft}
|
||||
onimported={onImported}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.new {
|
||||
.sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-highlight);
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s;
|
||||
color 0.14s,
|
||||
transform 0.14s;
|
||||
}
|
||||
.new:hover {
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-press);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.row {
|
||||
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 230px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
z-index: 40;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.14s;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row:hover {
|
||||
.mi:hover:not(:disabled) {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ic {
|
||||
.mi:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.mi.danger:hover {
|
||||
color: #f15e6c;
|
||||
}
|
||||
.mi .hint {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding-left: 14px;
|
||||
}
|
||||
.menu .rule {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 5px 8px;
|
||||
}
|
||||
|
||||
.lab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 2px 12px;
|
||||
}
|
||||
.lab.all {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius);
|
||||
padding: 13px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.artwrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
/* The Library card's own hover-play, verbatim in spirit: accent circle
|
||||
riding the art's corner, revealed by the card hover. */
|
||||
.hoverplay {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
background 0.18s ease;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
.hoverplay:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.card-wrap:hover .hoverplay,
|
||||
.hoverplay:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.art.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2.5px 7px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge.set {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.badge.un {
|
||||
background: rgba(232, 163, 61, 0.15);
|
||||
color: #e8a33d;
|
||||
}
|
||||
|
||||
.pinned-mark {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.more {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.card-wrap:hover .more,
|
||||
.more:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.more:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.card-menu {
|
||||
top: 48px;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14vh;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 24px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user