dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

-104
View File
@@ -1,104 +0,0 @@
<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>
+47 -23
View File
@@ -4,14 +4,15 @@
// 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 { player, ui, playLocalFile, goToView } from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
activeWordIndex,
wordClockMs,
wordfill,
totalOffsetMs,
nudgeOffset,
setAutoSync,
@@ -253,19 +254,26 @@
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">
{#if index === active && lyricsStore.wordFill && line.words?.length}
<!-- Karaoke word fill over REAL word times (the
wordfill mock's dialed variant A): each word
wipes across its OWN sung span, silence holds —
carried, never invented (the Noctis rule). A
line-level source still gets the whole-line
highlight and nothing more: the interpolated
sweep that tracked the layout instead of the
voice stays rejected. -->
<span
class="wline"
use:wordfill={{
line,
ms: wordClockMs(line, player.position ?? 0),
endBound: lyricsStore.lines[index + 1]?.time_ms
?? line.words[line.words.length - 1].time_ms + 2500,
}}
>
{#each line.words as word, wi (wi)}
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
<span class="fw">{word.text}</span>
{/each}
</span>
{:else}
@@ -331,7 +339,14 @@
{#snippet tools()}
<span class="tools">
{#if !big}
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
<button
class="tool"
title="Open the lyrics page"
onclick={() =>
goToView("lyrics", {
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
})}
>
<Icon name="expand" size={13} />
</button>
{/if}
@@ -615,20 +630,29 @@
}
/* ── karaoke rendering inside the active line ──
Word mode: crisp steps on the source's own word times. */
Word fill (the wordfill mock's dialed variant A): each word is its own
gradient-clip raster wiping across the word's real sung span — accent
left of the feathered boundary (±8%, mock-dialed), the pane's quieter
unsung tone right of it. --lyr-p per element (inherits: false;
registered @property in Lyrics.svelte). */
.wline {
display: block;
}
.w {
color: var(--text-secondary);
transition: color 0.12s ease;
.fw {
--lyr-p: -8%;
background-image: linear-gradient(
90deg,
var(--accent-hover) calc(var(--lyr-p) - 8%),
var(--text-secondary) calc(var(--lyr-p) + 8%)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.w + .w {
.fw + .fw {
margin-left: 0.32em;
}
.w.sung {
color: #fff;
}
.autochip {
flex: none;
+8 -4
View File
@@ -15,6 +15,7 @@
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
seekPlayer,
libraryStore,
artBroken,
markArtBroken,
@@ -943,7 +944,7 @@
const now = performance.now();
if (now - lastWaveSeekAt >= 75) {
lastWaveSeekAt = now;
api.playerControl("seek", target);
seekPlayer(target);
}
}
function waveUp(e) {
@@ -951,7 +952,7 @@
waveDragging = false;
player.scrubbing = false;
const target = waveTarget(e);
if (target != null) api.playerControl("seek", target);
if (target != null) seekPlayer(target);
try {
waveEl.releasePointerCapture(e.pointerId);
} catch {
@@ -1102,10 +1103,13 @@
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.
// machine — the row goes in as a stream row that
// plays through the resolver, not a fake local
// file. Its catalog id and cover ride along.
path: currentItem.stream ? null : currentItem.path,
art_path: currentItem.stream ? null : (currentItem.art ?? null),
trackId: currentItem.trackId ?? null,
artUrl: currentItem.stream ? (currentItem.artUrl ?? null) : null,
},
]}
/>
+12 -4
View File
@@ -17,6 +17,8 @@
togglePlayerExpanded,
showNowPlaying,
saveStream,
goToView,
engineTime,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import {
@@ -265,7 +267,11 @@
}
function openLyrics() {
menuOpen = false;
ui.lyricsFull = true; // the fullscreen overlay (LyricsOverlay.svelte)
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
// is gone.
goToView("lyrics", {
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
});
}
// ---- Save to library, from the ⋯ menu ---------------------------------
@@ -678,7 +684,9 @@
const now = performance.now();
if (force || now - lastScrubSeekAt >= 75) {
lastScrubSeekAt = now;
api.playerControl("seek", target);
// TRACK time in, ENGINE time out — a set-recording slice's
// engine clock is offset by the slice start.
api.playerControl("seek", engineTime(target));
}
}
@@ -706,7 +714,7 @@
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
if (activatingScratch || now - lastScrubSeekAt >= 50) {
lastScrubSeekAt = now;
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
api.playerControl("scratch", engineTime(target), e.clientX < scrubLastX ? -rate : rate);
}
} else if (!canScratch) {
regularScrub(target);
@@ -719,7 +727,7 @@
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target != null) {
if (scratchActive) api.playerControl("end-scratch", target);
if (scratchActive) api.playerControl("end-scratch", engineTime(target));
else regularScrub(target, true); // a simple click remains exact seek
}
draggingSeek = false;
+576 -28
View File
@@ -3,23 +3,55 @@
// 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".
//
// Identified-but-unowned rows are VERIFIED against the CATALOG while
// the review is open (Kyle: match them to "the streaming library part,
// not Soulseek") — a strict name+duration match against Deezer-backed
// catalog search. A hit attaches the catalog track id + artwork to the
// row, so the committed playlist plays it as a catalog track.
import * as api from "../lib/api.js";
import {
setlistScan,
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
// "file": a probed draft arrives via `draft`; "paste": textarea
// first; "youtube": a link field first — the set's own tracklist
// evidence (chapters/description/comments) becomes the draft.
mode = "file",
draft = null,
onimported = () => {},
} = $props();
let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null);
// The local listen door: the picked set recording + its optional
// pasted tracklist (B-flow: pick first, paste after).
let pickedFile = $state(null); // {path, name, duration_secs}
let fileTracklist = $state("");
let picking = $state(false);
let name = $state("");
let includeUnresolved = $state(true);
let committing = $state(false);
let parsing = $state(false);
let error = $state("");
// The listener: yt-dlp tool state + live scan progress.
let tool = $state(null);
let toolBusy = $state(false);
let denoBusy = $state(false);
// "deno" (managed or PATH) | "node"/"bun" (EJS may reject old ones —
// deno stays on offer) | null (no runtime at all).
const runtimeKind = $derived.by(() => {
const r = tool?.runtime;
if (!r) return null;
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
@@ -31,8 +63,108 @@
committing = false;
localDraft = null;
pasteText = "";
youtubeUrl = "";
pickedFile = null;
fileTracklist = "";
includeUnresolved = true;
scanning = null;
name = draft?.name ?? "";
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
scanning = { ...scanning, ...p };
});
});
async function downloadTool() {
if (toolBusy) return;
toolBusy = true;
error = "";
scanning = { stage: "tool" };
try {
await api.ytdlpDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
toolBusy = false;
scanning = null;
}
}
async function downloadDeno() {
if (denoBusy) return;
denoBusy = true;
error = "";
scanning = { stage: "runtime" };
try {
await api.denoDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
denoBusy = false;
scanning = null;
}
}
// Listens run in the BACKGROUND: starting one closes this modal and
// hands the whole job to the setlistScan store — its chip narrates the
// progress, and the finished draft re-opens the review from there.
function listenYoutube() {
if (!youtubeUrl.trim()) return;
if (!startYoutubeListen(youtubeUrl.trim())) return;
close();
}
// The local door picks FIRST: the panel that follows shows the chosen
// file and offers an optional pasted tracklist — the same declared
// evidence the YouTube door fetches for itself — before the listen
// starts. An empty box listens purely by ear.
async function pickLocalFile() {
if (picking || setlistScan.active) return;
picking = true;
error = "";
try {
const f = await api.playlistPickSetFile();
if (f) {
pickedFile = f;
fileTracklist = "";
}
} catch (e) {
error = String(e);
} finally {
picking = false;
}
}
function listenPicked() {
if (!pickedFile) return;
if (!startFileListen(pickedFile.path, fileTracklist.trim() || null, pickedFile.name))
return;
close();
}
function cancelScan() {
void api.playlistScanCancel();
}
const scanLabel = $derived.by(() => {
if (!scanning) return "";
if (scanning.stage === "tool") return "Downloading yt-dlp…";
if (scanning.stage === "runtime")
return `Downloading deno…${scanning.total ? ` ${Math.round(((scanning.received ?? 0) / scanning.total) * 100)}%` : ""}`;
if (scanning.stage === "audio")
return `Fetching the set's audio…${scanning.pct != null ? ` ${Math.round(scanning.pct)}%` : ""}`;
if (scanning.stage === "decode") return "Decoding the set…";
if (scanning.stage === "listen")
return `Listening… window ${scanning.window ?? 0} of ${scanning.total ?? "?"}`;
return "Working…";
});
async function previewPaste() {
if (!pasteText.trim() || parsing) return;
@@ -45,6 +177,26 @@
} else {
localDraft = parsed;
if (!name) name = parsed.name ?? "";
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
async function previewYoutube() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportYoutube(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The set's tracklist parsed to nothing usable.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
}
} catch (e) {
error = String(e);
@@ -55,11 +207,109 @@
const kept = $derived.by(() => {
if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
// only gates the TRUE unknowns.
return includeUnresolved
? working.entries
: working.entries.filter((e) => e.resolution === "local");
: working.entries.filter((e) => e.resolution !== "unresolved");
});
// ---- catalog verification of stream rows, live in the review -------
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
let checkGen = 0;
const norm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/// Strict match: at least one exact side (title or artist), both at
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
/// ties so a famous track beats a namesake.
function pickCatalogMatch(tracks, e) {
const wantT = norm(e.title);
const wantA = norm(e.artist);
let best = null;
let bestScore = 0;
for (const t of tracks ?? []) {
const tT = norm(t.title);
const tA = norm(t.artist);
let score = 0;
if (tT === wantT) score += 4;
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
if (tA === wantA) score += 4;
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
if (score < 6) continue;
if (
e.duration_secs &&
t.duration_secs &&
Math.abs(t.duration_secs - e.duration_secs) <= 7
)
score += 2;
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
/// reads and writes `checks`, and doing that inside an $effect made
/// every row verdict re-trigger the effect, which bumped the
/// generation and killed its own walk — row one froze at "matching…"
/// forever. Explicit kicks from the draft-arrival sites, via a
/// microtask, keep reactivity out of the loop.
function startChecks(w) {
checks = {};
const gen = ++checkGen;
queueMicrotask(() => void verifyStreams(w, gen));
}
async function verifyStreams(w, gen) {
for (const e of w.entries) {
if (e.resolution !== "stream") continue;
if (gen !== checkGen || !open) return;
checks[e.ordinal] = "checking";
try {
// Anchor-exact first: a row the listener heard carries the
// matched release's ISRC, and one /track/isrc: lookup beats
// any name matcher. A miss falls through to the name walk.
let hit = null;
let anchored = false;
if (e.isrc) {
hit = await api.catalogTrackByIsrc(e.isrc);
if (gen !== checkGen) return;
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
e.track_id = Number(hit.id);
e.art_url = hit.cover ?? null;
if (!e.album) e.album = hit.album ?? null;
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
checks[e.ordinal] = {
album: hit.album ?? "",
cover: hit.cover ?? null,
anchored,
};
} else {
checks[e.ordinal] = "none";
}
} catch {
if (gen === checkGen) checks[e.ordinal] = "none";
}
await new Promise((r) => setTimeout(r, 250));
}
}
const checkedFound = $derived(
Object.values(checks).filter((v) => v && v !== "none" && v !== "checking").length,
);
async function commit() {
if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim();
@@ -71,6 +321,7 @@
working.description ?? null,
working.kind,
kept,
working.cover_url ?? null,
);
onimported(id, finalName);
} catch (e) {
@@ -80,6 +331,7 @@
}
function close() {
checkGen += 1; // stop any verification walk
open = false;
}
@@ -111,6 +363,8 @@
soulstream: "SoulStream backup",
text: "Pasted text",
folder: "Folder",
youtube: "YouTube",
listened: "Recognized by ear",
};
</script>
@@ -132,6 +386,10 @@
<h2>
{#if working}
Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
Import a DJ set from YouTube
{:else}
Paste a tracklist
{/if}
@@ -142,35 +400,177 @@
</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>
{#if pickedFile}
<div class="srcfile">
<Icon name="music" size={14} />
<span class="fname" title={pickedFile.path}>{pickedFile.name}</span>
{#if pickedFile.duration_secs != null}
<span class="flen">{fmtTime(pickedFile.duration_secs * 1000)}</span>
{/if}
</div>
<p class="lead">
Listening identifies the tracks by sound. If the set came with a
tracklist, paste it too — it names the exact <b>version</b> played
where the ear can only name the release it matched, and fills
stretches nothing recognized. Leave it empty to listen purely by ear.
</p>
<textarea
class="tl"
bind:value={fileTracklist}
placeholder={"00:00 Worakls - Red Dressed (Ben Böhmer Remix)\n05:30 Overmono - So U Kno\n…"}
spellcheck="false"
></textarea>
<div class="hint">
Optional. Only timestamped lines count — the stamp anchors each
name to its place in the mix.
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={() => (pickedFile = null)}>
Back
</button>
<button
class="btn primary"
onclick={listenPicked}
disabled={setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
</footer>
{:else if mode === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">{scanLabel}</div>
{#if scanning.hit}
<div class="scanhit">{scanning.hit}</div>
{/if}
</div>
<footer>
<button class="btn ghost" onclick={cancelScan}>Cancel</button>
</footer>
{:else}
<p class="lead">
Paste a set's link. <b>Fetch tracklist</b> reads the video's own
chapters, description, or a commented tracklist — free and instant.
<b>Listen</b> downloads the audio and identifies the tracks by
sound: a few minutes, and unreleased material stays an honest ID.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://www.youtube.com/watch?v=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutube()}
/>
<div class="toolline">
{#if tool?.present}
<span class="toolok">
yt-dlp {tool.version ?? ""}{tool.managed ? "" : " (from PATH)"}
</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
update
</button>
{:else}
<span>Listening needs the downloader tool:</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
{toolBusy ? "downloading…" : "download yt-dlp (~17 MB, official nightly)"}
</button>
{/if}
<span class="toolsep">·</span>
{#if runtimeKind === "deno"}
<span class="toolok">deno</span>
{:else if runtimeKind}
<span>runtime: {runtimeKind}</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "get deno (recommended, ~40 MB)"}
</button>
{:else}
<span>and a JS runtime:</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "download deno (~40 MB, official release)"}
</button>
{/if}
<span class="toolsep">·</span>
<button
class="linkbtn"
onclick={pickLocalFile}
disabled={parsing || picking || setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: ""}
>
or listen to a local file…
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn ghost"
onclick={listenYoutube}
disabled={!youtubeUrl.trim() ||
parsing ||
!tool?.present ||
!tool?.runtime ||
setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: !tool?.present
? "Download yt-dlp first"
: !tool?.runtime
? "Download a JS runtime first"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
<button
class="btn primary"
onclick={previewYoutube}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracklist"}
</button>
</footer>
{/if}
{:else}
<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>
{/if}
{: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
<b class="ok">{working.resolved_count}</b> in library
{#if working.stream_count > 0}
&thinsp;·&thinsp;<b class="st">{checkedFound}</b>/{working.stream_count} in catalog
{/if}
{#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unknown
{/if}
</span>
</div>
@@ -187,8 +587,8 @@
{#each working.entries as e (e.ordinal)}
<div
class="row"
class:unresolved={e.resolution !== "local"}
class:excluded={!includeUnresolved && e.resolution !== "local"}
class:unresolved={e.resolution === "unresolved"}
class:excluded={!includeUnresolved && e.resolution === "unresolved"}
>
<span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"}
@@ -215,8 +615,41 @@
? "relinked"
: "tags"}
</span>
{:else if e.resolution === "stream"}
{#if checks[e.ordinal] === "checking"}
<span class="tag st dim">matching…</span>
{:else if checks[e.ordinal] && checks[e.ordinal] !== "none"}
<span
class="tag st"
title={`${checks[e.ordinal].anchored ? "Exact recording match (ISRC)" : "Matched in the catalog"}${checks[e.ordinal].album ? ` — ${checks[e.ordinal].album}` : ""} · streams like any catalog track`}
>
<Icon name="check" size={11} />
catalog
</span>
{:else if checks[e.ordinal] === "none"}
<span
class="tag un"
title="No confident catalog match — still plays as a by-name stream"
>
not in catalog
</span>
{:else}
<span
class="tag st dim"
title="Identified but not in your library — plays as a stream"
>
stream
</span>
{/if}
{:else if e.resolution === "segment"}
<span
class="tag st dim"
title="Unidentified — will play this stretch of the set's own recording"
>
set audio
</span>
{:else}
<span class="tag un" title="No local match — kept as a placeholder">
<span class="tag un" title="Identity too thin to play — kept as a placeholder">
unresolved
</span>
{/if}
@@ -242,7 +675,7 @@
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste"}
{#if mode === "paste" || mode === "youtube"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -336,6 +769,111 @@
textarea:focus {
border-color: var(--accent);
}
input.url {
width: 100%;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
outline: none;
}
input.url:focus {
border-color: var(--accent);
}
/* The local door's picked-file line (B-flow panel). */
.srcfile {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
font-size: 12.5px;
color: var(--text-muted);
}
.srcfile .fname {
font-family: Consolas, monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srcfile .flen {
color: var(--text-muted);
margin-left: auto;
flex: none;
}
textarea.tl {
min-height: 170px;
}
.hint {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 7px;
}
.toolline {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.toolok {
color: #3ecf7e;
}
.toolsep {
opacity: 0.5;
}
.linkbtn {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--accent);
cursor: pointer;
}
.linkbtn:hover {
text-decoration: underline;
}
.linkbtn:disabled {
opacity: 0.5;
cursor: default;
}
.scanbox {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 42px 0 30px;
}
.scanline {
font-size: 13.5px;
color: var(--text-primary);
}
.scanhit {
font-size: 12.5px;
color: var(--text-secondary);
}
.ring {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.srcline {
display: flex;
@@ -376,6 +914,9 @@
.counts .ok {
color: #3ecf7e;
}
.counts .st {
color: #58b6e8;
}
.counts .un {
color: #e8a33d;
}
@@ -465,6 +1006,13 @@
background: rgba(62, 207, 126, 0.13);
color: #3ecf7e;
}
.tag.st {
background: rgba(88, 182, 232, 0.13);
color: #58b6e8;
}
.tag.dim {
opacity: 0.65;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
+109 -40
View File
@@ -3,6 +3,13 @@
// 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.
//
// The open menu is PORTALED to <body> and positioned fixed (the SaveMenu
// idiom): triggers live inside transformed hover overlays (.rowbtns,
// .rowact) and glass headers, and a transform or backdrop-filter ancestor
// both traps z-index and re-anchors position:fixed — the third
// menu-trapped-in-a-context episode made this the standing rule. Scoped
// styles still apply to the portaled node (the hash class travels with it).
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -10,15 +17,22 @@
let {
// () => [{ title, artist, album, durationSecs, path, artPath }]
tracks,
align = "left", // "left" | "right"
align = "left", // preferred horizontal edge: "left" | "right" of trigger
iconSize = 13,
title = "Add to playlist",
compact = false, // icon-only trigger
exclude = null, // playlist id to omit (a playlist's own rows)
} = $props();
let open = $state(false);
let busy = $state(false);
let doneFor = $state(null); // playlist id that just accepted rows
let triggerEl = $state(null);
let menuEl = null;
const targets = $derived(
playlistStore.playlists.filter((pl) => pl.id !== exclude),
);
$effect(() => {
if (open && !playlistStore.loaded) refreshPlaylists();
@@ -30,6 +44,56 @@
doneFor = null;
}
/** Portal the menu to <body>, then place it against the trigger: below
* and edge-aligned, flipped above when the viewport runs out. Measured
* after mount so the content-driven width is what gets clamped. */
function overlay(node) {
document.body.appendChild(node);
const r = triggerEl.getBoundingClientRect();
const mw = node.offsetWidth;
const mh = node.offsetHeight;
let left = align === "right" ? r.right - mw : r.left;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
let top = r.bottom + 7;
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - 7 - mh);
node.style.top = `${top}px`;
node.style.left = `${left}px`;
node.style.visibility = "visible";
return {
destroy() {
node.remove();
},
};
}
// Close on outside click, Escape, resize, or any scroll OUTSIDE the menu
// (the playlist list scrolls inside it) — a fixed menu must not drift away
// from its trigger.
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (menuEl?.contains(e.target) || triggerEl?.contains(e.target)) return;
open = false;
};
const onScroll = (e) => {
if (menuEl && (e.target === menuEl || menuEl.contains(e.target))) return;
open = false;
};
const onKey = (e) => {
if (e.key === "Escape") open = false;
};
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", onScroll, true);
document.addEventListener("keydown", onKey, true);
window.addEventListener("resize", onScroll);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", onScroll, true);
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("resize", onScroll);
};
});
function payload() {
return (tracks() ?? []).map((t) => ({
title: t.title ?? "",
@@ -38,6 +102,11 @@
duration_secs: t.durationSecs ?? t.duration_secs ?? null,
path: t.path ?? null,
art_path: t.artPath ?? t.art_path ?? null,
// A pathless row is a STREAM row; these anchor it — the catalog
// track id for identity, the cover URL for artwork it has no
// local file to read from.
track_id: t.trackId != null ? String(t.trackId) : (t.track_id ?? null),
art_url: t.artUrl ?? t.art_url ?? null,
}));
}
@@ -84,16 +153,11 @@
busy = false;
}
}
function windowClick() {
open = false;
}
</script>
<svelte:window onclick={open ? windowClick : undefined} />
<div class="plm">
<div class="plm" onkeydown={(e) => e.stopPropagation()}>
<button
bind:this={triggerEl}
class="plm-trigger"
class:compact
{title}
@@ -104,31 +168,39 @@
<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>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div
class="plm-menu"
bind:this={menuEl}
use:overlay
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<button class="plm-item new" onclick={addToNew} disabled={busy}>
<Icon name="plus" size={14} /> New playlist
</button>
{#if targets.length}
<div class="plm-rule"></div>
<div class="plm-list">
{#each targets 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}
<style>
.plm {
@@ -145,23 +217,20 @@
display: grid;
place-items: center;
}
/* Fixed positioning escapes overflow clipping and every stacking context;
placed (and made visible) by the overlay action once measured. */
.plm-menu {
position: absolute;
top: calc(100% + 7px);
left: 0;
position: fixed;
visibility: hidden;
min-width: 210px;
max-width: 270px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 80;
z-index: 1000;
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;
+185
View File
@@ -0,0 +1,185 @@
<script>
// The listen's presence while the import modal is closed — the import
// toast's dark glass pill, one berth higher so both can show at once.
// Running: live stage + window count + a cancel. Done: click to open
// the review. Error: what happened + dismiss.
import { goToView } from "../lib/state.svelte.js";
import { setlistScan, dismissScan } from "../lib/setlistScan.svelte.js";
const label = $derived.by(() => {
if (setlistScan.error) return setlistScan.error;
if (setlistScan.draft) {
const n = setlistScan.draft.entries?.length ?? 0;
return `${setlistScan.label} — ${n} row${n === 1 ? "" : "s"}`;
}
switch (setlistScan.stage) {
case "tool":
return "Fetching yt-dlp…";
case "runtime":
return "Fetching deno…";
case "audio":
return `Fetching the set's audio…${
setlistScan.pct != null ? ` ${Math.round(setlistScan.pct)}%` : ""
}`;
case "decode":
return "Decoding the set…";
case "listen":
return `Listening… ${setlistScan.window} of ${setlistScan.total || "?"}`;
default:
return "Listening…";
}
});
function review() {
setlistScan.wantsReview = true;
goToView("playlists");
}
</script>
{#if setlistScan.active}
<div class="layer">
<div class="toast" class:err={!!setlistScan.error}>
{#if setlistScan.draft}
<button class="body" onclick={review} title="Open the review">
<span class="mark ok">✓</span>
<span>{label} — <b>review</b></span>
</button>
<button
class="x"
onclick={dismissScan}
title="Dismiss — the hearing is remembered, a re-listen is instant"
>×</button>
{:else if setlistScan.error}
<span class="body">
<span class="mark warn">!</span>
<span>{label}</span>
</span>
<button class="x" onclick={dismissScan} title="Dismiss">×</button>
{:else}
<span class="body">
<span class="mark pulse">▸</span>
<span>{label}</span>
{#if setlistScan.stage === "listen" && setlistScan.hit}
<span class="hit">{setlistScan.hit}</span>
{/if}
</span>
<button class="x" onclick={dismissScan} title="Cancel the listen">×</button>
{/if}
</div>
</div>
{/if}
<style>
/* The import toast's berth, one pill higher, so both can show at once. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px + 44px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
animation: chip-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast.err {
border-color: rgba(240, 205, 119, 0.4);
}
.body {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 4px 8px 4px 0;
color: inherit;
font: inherit;
cursor: default;
max-width: 520px;
}
button.body {
cursor: pointer;
border-radius: 99px;
}
button.body:hover {
color: var(--accent);
}
.hit {
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.x {
background: none;
border: 0;
color: rgba(255, 255, 255, 0.55);
font: inherit;
font-size: 15px;
line-height: 1;
padding: 4px 8px;
border-radius: 99px;
cursor: pointer;
}
.x:hover {
color: #fff;
}
.mark {
flex: none;
font-style: normal;
}
.mark.pulse {
color: var(--accent);
animation: chip-pulse 1s ease-in-out infinite;
}
@keyframes chip-pulse {
50% {
opacity: 0.35;
}
}
.mark.ok {
color: var(--accent);
}
.mark.warn {
color: #f0cd77;
}
b {
font-weight: 800;
}
@keyframes chip-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
.mark.pulse {
animation: none;
}
}
</style>