forked from Keelrleek/SoulStream
dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
 · <b class="st">{checkedFound}</b>/{working.stream_count} in catalog
|
||||
{/if}
|
||||
{#if working.unresolved_count > 0}
|
||||
 · <b class="un">{working.unresolved_count}</b> unresolved
|
||||
 · <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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user