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

+14 -2
View File
@@ -4,7 +4,6 @@
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";
@@ -23,8 +22,10 @@
import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte";
import CatalogDiscography from "./views/CatalogDiscography.svelte";
import Lyrics from "./views/Lyrics.svelte";
import FollowToast from "./components/FollowToast.svelte";
import ImportToast from "./components/ImportToast.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte";
import {
ui,
initEvents,
@@ -48,6 +49,7 @@
catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum,
catalogDiscography: CatalogDiscography,
lyrics: Lyrics,
};
const CurrentView = $derived(views[ui.view] ?? Home);
@@ -82,6 +84,16 @@
// --bar-h + air rather than --pane-pad. Its ambience wash is the one
// exception and deliberately runs to the pane top behind the glass.
"home",
// The playlist editor follows the album-page rule: its hero banner
// runs under the frost and the page takes padding-top: --pane-pad.
"playlist",
// The playlist shelf follows the Library exception: its blurred
// ambience pins behind the glass and must begin at the very top of
// the pane, so the page carries its own top inset instead.
"playlists",
// The lyrics room follows the Library exception too: its full-bleed
// duotone backdrop pins behind the glass from the very pane top.
"lyrics",
]);
initEvents();
@@ -127,8 +139,8 @@
<PlayerBar />
<FollowToast />
<ImportToast />
<SetlistScanChip />
<PreviewOverlay />
<LyricsOverlay />
<LyricsSyncEditor />
<ArtworkViewer />
<ImportWindow />
-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>
+42 -4
View File
@@ -73,7 +73,8 @@ export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art");
export const playStream = (username, path, size, duration) =>
invoke("play_stream", { username, path, size, duration });
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
export const playLocal = (path, duration, startSecs = null) =>
invoke("play_local", { path, duration, startSecs });
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
export const playerControl = (action, value, rate) =>
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
@@ -81,7 +82,8 @@ export const playerControl = (action, value, rate) =>
// playback from events, so after a reload this is the only way to find out that
// audio is still running — see `restoreSession`.
export const playerState = () => invoke("player_state");
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
invoke("waveform", { path, buckets, startSecs, endSecs });
export const libraryAlbums = () => invoke("library_albums");
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
@@ -170,6 +172,8 @@ export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null,
artPath: fields.artPath ?? null,
trackId: fields.trackId ?? null,
artUrl: fields.artUrl ?? null,
});
// Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) =>
@@ -181,11 +185,44 @@ export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id })
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 const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
// The local door is split pick/scan so the modal can collect an optional
// pasted tracklist between the two.
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
export const playlistScanFile = (path, tracklist = null) =>
invoke("playlist_scan_file", { path, tracklist });
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
export const ytdlpStatus = () => invoke("ytdlp_status");
export const ytdlpDownload = () => invoke("ytdlp_download");
export const denoDownload = () => invoke("deno_download");
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
invoke("playlist_import_commit", {
name,
description: description ?? null,
kind,
entries,
coverUrl,
});
// 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 });
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
export const lyricModelStatus = () => invoke("lyric_model_status");
export const lyricModelDownload = () => invoke("lyric_model_download");
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
export const vocalSepStatus = () => invoke("vocal_sep_status");
export const vocalSepDownload = () => invoke("vocal_sep_download");
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
invoke("lyric_prep_request", {
path: path ?? null,
stream,
durationSecs: durationSecs ?? null,
lines,
});
// Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
@@ -220,6 +257,7 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
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 catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
Binary file not shown.
+13
View File
@@ -59,6 +59,7 @@ const ROOTS = new Set([
"library",
"downloads",
"settings",
"playlists",
"catalog",
]);
@@ -248,6 +249,18 @@ export function trail() {
},
);
}
// A playlist sits under the Playlists shelf the same way. The shelf is a
// root, so when the page was reached through it the origin crumb already
// says Playlists; this branch covers the direct arrivals (a pinned
// playlist in the sidebar) by borrowing the shelf from history or
// synthesising it.
if (here.view === "playlist" && nav.origin?.view !== "playlists") {
const known = [...nav.stack].reverse().find((e) => e.view === "playlists");
crumbs.push(
known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" },
);
}
crumbs.push(here);
return crumbs;
}
+101
View File
@@ -0,0 +1,101 @@
// Background DJ-set listening. A scan OUTLIVES the import modal: starting a
// listen closes the modal, progress rides a global chip above the player
// bar, the app stays fully usable, and the finished draft waits on the chip
// until it is reviewed or dismissed. One scan at a time — the backend's
// cancel flag is global by design.
import * as api from "./api.js";
export const setlistScan = $state({
active: false,
mode: null, // "youtube" | "file"
label: "",
stage: null, // "tool" | "runtime" | "audio" | "decode" | "listen"
window: 0,
total: 0,
pct: null,
hit: null,
draft: null, // finished ImportDraft awaiting review
error: null,
wantsReview: false, // chip clicked; the Playlists view consumes this
});
let subscribed = false;
function subscribe() {
if (subscribed) return;
subscribed = true;
api.onEvent("playlist:scan-progress", (p) => {
// A ready chip must not be mutated by stray late events.
if (!setlistScan.active || setlistScan.draft || !p?.stage) return;
setlistScan.stage = p.stage;
if (p.window != null) setlistScan.window = p.window;
if (p.total != null) setlistScan.total = p.total;
setlistScan.pct = p.pct ?? null;
if (p.hit != null) setlistScan.hit = p.hit;
});
}
async function run(mode, label, call) {
subscribe();
Object.assign(setlistScan, {
active: true,
mode,
label,
stage: mode === "youtube" ? "audio" : "decode",
window: 0,
total: 0,
pct: null,
hit: null,
draft: null,
error: null,
wantsReview: false,
});
try {
const draft = await call();
if (draft) {
setlistScan.draft = draft;
setlistScan.label = draft.name ?? label;
} else {
// Defensive: a null draft must not wedge the chip.
setlistScan.active = false;
}
} catch (e) {
const message = String(e);
if (message.includes("canceled")) setlistScan.active = false;
else setlistScan.error = message;
}
}
/** Kick a YouTube listen into the background. False = one already runs. */
export function startYoutubeListen(url) {
if (setlistScan.active) return false;
void run("youtube", "YouTube set", () => api.playlistScanYoutube(url));
return true;
}
/** Listen to an already-picked set recording in the background, with an
* optionally pasted tracklist riding along. False = one already runs. */
export function startFileListen(path, tracklist = null, label = "Set recording") {
if (setlistScan.active) return false;
void run("file", label, () => api.playlistScanFile(path, tracklist));
return true;
}
/** Hand the finished draft to the review and clear the chip. */
export function takeScanDraft() {
const draft = setlistScan.draft;
setlistScan.draft = null;
setlistScan.active = false;
setlistScan.wantsReview = false;
return draft;
}
/** Cancel a running scan, or drop a finished/failed one unseen. */
export function dismissScan() {
if (setlistScan.active && !setlistScan.draft && !setlistScan.error) {
void api.playlistScanCancel();
}
setlistScan.active = false;
setlistScan.draft = null;
setlistScan.error = null;
setlistScan.wantsReview = false;
}
+275 -27
View File
@@ -1624,7 +1624,9 @@ const VIEW_LABELS = {
library: "Your Library",
downloads: "Downloads",
settings: "Settings",
playlists: "Playlists",
playlist: "Playlist",
lyrics: "Lyrics",
};
const VIEW_GLYPHS = {
home: "home",
@@ -1632,7 +1634,9 @@ const VIEW_GLYPHS = {
library: "library",
downloads: "download",
settings: "settings",
playlists: "playlist",
playlist: "music",
lyrics: "music",
};
/** Re-open a recorded place. The one function the history arrows and the trail
@@ -3384,10 +3388,37 @@ export async function saveStream(scope) {
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
function currentArtistAlbum() {
const item = player.queue[player.queueIndex] ?? null;
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
// so its own fields are the whole identity — splitting would offer the
// playlist name as an artist (and negative-cache the click into silence).
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
const split = sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
// Per field, the item's own artist/album win — the player bar's rule.
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
}
/** Anchor-exact album jump for a row that knows its catalog track id but not
* its record (playlist stream rows from before commits stored albums): one
* name search, then the ID picks the track — never a name guess. */
async function openAlbumForCurrentTrackId(opts = {}) {
const item = player.queue[player.queueIndex] ?? null;
const trackId = item?.trackId;
if (trackId == null || !item?.artist || !item?.title) return false;
try {
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
if (hit?.album_id != null) {
openCatalogAlbum(hit.album_id, { trackId, ...opts });
return true;
}
} catch {
// fall through to the name path
}
return false;
}
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
@@ -3395,15 +3426,16 @@ export function openCurrentArtist() {
const [artist] = currentArtistAlbum();
if (artist) openArtistByName(artist);
}
export function openCurrentAlbum() {
export async function openCurrentAlbum() {
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId);
return;
}
if (await openAlbumForCurrentTrackId()) return;
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album);
}
export function openCurrentSong() {
export async function openCurrentSong() {
const trackTitle = player.track?.title;
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId, {
@@ -3412,6 +3444,7 @@ export function openCurrentSong() {
});
return;
}
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album, { trackTitle });
}
@@ -3827,6 +3860,18 @@ async function cachedStreamSourceFor(trackId, artist, title) {
for (const key of streamSourceKeys(trackId, artist, title)) {
const src = map.get(key);
if (!src) continue;
// A remembered file whose NAME does not even contain the track's
// base title is a mis-paired memory (the 2026-08-20 incident:
// three unrelated rows each "remembered" the same Weezer FLAC and
// played it from the warm cache forever). Delete it and resolve
// normally — a wrongly-dropped legit source only costs one search.
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
if (base.length >= 4 && fileName && !fileName.includes(base)) {
map.delete(key);
saveStreamSources();
continue;
}
try {
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
} catch {
@@ -5542,7 +5587,7 @@ export function initEvents() {
// the reader back where they were. Taken exactly once, so an
// ordinary resume later in the same track is unaffected.
const seekTo = takeResumeSeek();
if (seekTo != null) api.playerControl("seek", seekTo);
if (seekTo != null) seekPlayer(seekTo);
break;
}
case "paused":
@@ -5559,7 +5604,19 @@ export function initEvents() {
if (ev.gapless) advanceGaplessly();
else nextTrack(true);
break;
case "position":
case "position": {
// A set-recording slice shows slice-relative time, keeps its
// own duration (the engine reports the whole set's), and
// cuts to the next row at its end boundary.
const seg = player.queue[player.queueIndex]?.segment;
if (seg) {
const rel = Math.max(0, ev.secs - seg.start);
latestPlayerPosition = rel;
if (!player.scrubbing && ui.windowActive) player.position = rel;
listenTick(rel);
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
break;
}
latestPlayerPosition = ev.secs;
if (ev.duration != null) latestPlayerDuration = ev.duration;
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
@@ -5568,6 +5625,7 @@ export function initEvents() {
}
listenTick(ev.secs);
break;
}
case "error":
player.status = "idle";
player.error = ev.message;
@@ -5593,8 +5651,7 @@ export function initEvents() {
// Fully cached → final waveform, and the peer's slot is
// definitely free, so make sure the next track is pre-buffering.
if (cur?.stream) loadStreamWaveform(cur, true);
preloadNextStream();
tryAppendNext();
prepareFollowing();
} else if (
ui.windowActive
&& cur?.stream
@@ -5823,22 +5880,33 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
}
/** Play an album/playlist (list of library tracks) starting at `start`.
* `artPath` is the album cover; playlist tracks carry their own art_path. */
* `artPath` is the album cover; playlist tracks carry their own art_path.
*
* A row with `needsResolve` is a playlist STREAM row: full identity, no
* source yet. It queues as a pending item that resolves through the normal
* ladder when reached (or by the look-ahead a track early), so Play on a
* mixed playlist keeps every row in order instead of skipping the streams. */
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
autoAlbum = null;
player.queue = tracks.map((t) => ({
path: t.path,
path: t.path ?? 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),
art: t.art_path ?? (t.needsResolve ? null : artPath),
artUrl: t.artUrl ?? null,
trackId: t.trackId ?? null,
quality: t.path ? localQuality(t) : null,
// 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,
// A set-recording SLICE: {start, end} seconds into the file at
// `path` (end null = to the file's end). The playback layer seeks
// in at start and cuts at end; display time is slice-relative.
segment: t.segment ?? null,
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
@@ -5863,6 +5931,15 @@ function playQueueIndex(i) {
};
player.status = "buffering";
player.duration = item.duration;
// A playlist stream row queued without a source yet: resolve it now, then
// re-enter with the source attached. The item mutates in place, so a
// repeat visit (prev/next, repeat-all) is instant.
if (!item.path && !item.stream) {
player.track.source = "stream";
player.streamTotal = 0;
void resolvePendingQueueItem(i, item);
return;
}
if (item.stream) {
// Remote track streamed from a peer while it downloads to cache.
rememberStreamSource(item);
@@ -5881,8 +5958,7 @@ function playQueueIndex(i) {
.then(() => {
// Ordered behind play_stream so the gapless append can't
// race the generation bump on the audio thread.
preloadNextStream();
tryAppendNext();
prepareFollowing();
})
.catch((e) => {
player.error = String(e);
@@ -5891,12 +5967,27 @@ function playQueueIndex(i) {
return;
}
player.streamTotal = 0;
if (item.segment) {
// A set-recording slice: the lane shows the SLICE's own waveform (a
// ranged decode — a 90-minute skyline says nothing about a 4-minute
// stretch), and the decoder comes up at the slice start. The early
// cut at segment.end is enforced by the position handler.
loadWaveform(item.path, item.segment);
setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
player.error = String(e);
});
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
// a pending row after one still resolves a track early.
prepareFollowing();
return;
}
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration).catch((e) => {
player.error = String(e);
});
preloadFollowingTrack();
prepareFollowing();
}
/** Play a specific queue position (Now Playing panel track clicks). */
@@ -5910,14 +6001,23 @@ function gaplessNextIndex() {
if (player.shuffle) return -1;
if (player.repeat === "one") return player.queueIndex;
const next = player.queueIndex + 1;
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
// Set-recording slices never join the gapless machinery: a preload or
// append would come up at the FILE's start, not the slice's, and the
// slice's early cut is driven by the position handler, not the
// engine's track-ended.
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
return -1;
}
return i;
}
function preloadFollowingTrack() {
const next = gaplessNextIndex();
if (next < 0) return;
const item = player.queue[next];
if (item.stream) return; // gapless preload is local-only
// Gapless preload is local-only; a pending stream row has no path at all.
if (item.stream || !item.path) return;
api.preloadLocal(item.path, item.duration).catch(() => {});
}
@@ -5967,6 +6067,21 @@ async function tryAppendNext() {
}
}
/** Arm every next-track preparation that applies: a LOCAL next preloads
* into the sink, a resolved STREAM next pre-buffers and offers the
* gapless append, and a PENDING playlist row resolves a track early so
* it can join the same chain. Each is a cheap no-op when it does not
* apply — call this at every track start and every gapless boundary,
* whatever the current source is. (The playlist lesson: only the
* album-shaped paths were armed, so a mixed playlist went seamless for
* exactly one boundary and stalled on every one after it.) */
function prepareFollowing() {
preloadFollowingTrack();
preloadNextStream();
tryAppendNext();
scheduleQueueLookahead();
}
function advanceGaplessly() {
const next = gaplessNextIndex();
// The backend only labels a boundary gapless when it has this source
@@ -6000,15 +6115,14 @@ function advanceGaplessly() {
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item);
preloadNextStream();
tryAppendNext();
prepareFollowing();
scheduleAlbumFill();
return;
}
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
preloadFollowingTrack();
prepareFollowing();
}
// ---- album continuation ----
@@ -6164,6 +6278,118 @@ async function maybeExtendAlbumQueue() {
}
}
// ---- pending playlist stream rows -------------------------------------------
// A mixed playlist queues its stream rows as PENDING items — identity, no
// source. They resolve through the same ladder the album fill uses: the
// remembered-source memory first (free), then the sanitized search. One
// resolution is in flight per item, shared between play-on-reach and the
// look-ahead, so hammering next can't stack searches for one row.
const pendingResolves = new WeakMap();
/** Resolve one pending descriptor to a playable source, or null. */
async function resolveStreamSourceFor(t, budgetMs = 7000) {
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
if (known) {
return {
path: known.path,
duration: known.duration ?? t.duration ?? null,
stream: { username: known.username, size: known.size },
quality: known.quality ?? null,
};
}
if (app.connection !== "online") return null;
const goodCount = (rs) =>
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
let artistIncluded = true;
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
if (!responses.length && fallback && fallback !== primary) {
responses = await collectSources(fallback, budgetMs, goodCount);
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
} else if (primary) {
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
}
const best = songCandidates(
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
)[0];
if (!best) return null;
return {
path: best.file.path,
duration: best.file.duration ?? t.duration ?? null,
stream: { username: best.username, size: best.file.size },
quality: qualityOf(best.file),
};
}
/** Single-flight source resolution for a queue item; mutates it in place. */
function resolveQueueItem(item) {
const inFlight = pendingResolves.get(item);
if (inFlight) return inFlight;
const p = resolveStreamSourceFor(item)
.then((src) => {
if (src) Object.assign(item, src);
return src;
})
.catch(() => null)
.finally(() => pendingResolves.delete(item));
pendingResolves.set(item, p);
return p;
}
// Two sourceless rows in a row is a signal (a whole stretch that cannot
// resolve), and auto-advancing through a long dead playlist would fire one
// Soulseek search per row — the exact flood the album fill's delay exists to
// prevent. The streak resets on any success.
let pendingFailStreak = 0;
async function resolvePendingQueueItem(i, item) {
const src = await resolveQueueItem(item);
// The world may have moved on mid-search: queue replaced, or the user is
// on another row. An enriched item keeps its source either way.
if (player.queue[i] !== item || player.queueIndex !== i) return;
if (src) {
pendingFailStreak = 0;
playQueueIndex(i);
return;
}
item.unplayable = true;
player.error = `No streamable sources found for “${item.title}”`;
pendingFailStreak += 1;
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
pendingFailStreak = 0;
stopPlayback();
return;
}
nextTrack(true);
}
// Resolve the NEXT pending row a track early, on the album fill's own
// anti-flood delay, so the boundary into a stream row starts instantly
// instead of stalling on a live search.
let queueLookaheadTimer = null;
function scheduleQueueLookahead() {
clearTimeout(queueLookaheadTimer);
queueLookaheadTimer = setTimeout(() => {
queueLookaheadTimer = null;
void resolveNextPendingItem();
}, ALBUM_FILL_DELAY_MS);
}
async function resolveNextPendingItem() {
if (player.shuffle) return; // the next index is unknowable
const next = player.queueIndex + 1;
const item = player.queue[next];
if (!item || item.path || item.stream || item.unplayable) return;
const src = await resolveQueueItem(item);
if (!src || !player.queue.includes(item)) return;
// Now a normal upcoming stream: pre-buffer it and offer the gapless
// append, exactly as if it had been queued with its source.
preloadNextStream();
tryAppendNext();
}
// ---- waveform ----
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
@@ -6177,24 +6403,32 @@ let waveReqPath = null;
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
let waveRefreshAt = 0;
async function loadWaveform(path) {
async function loadWaveform(path, range = null) {
player.waveform = [];
player.wavePending = !!path;
waveReqPath = path;
// A range = a set-recording slice's lane; each slice keys its own
// request so two slices of one file never answer for each other.
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
waveReqPath = req;
if (!path) return;
try {
const peaks = await api.waveform(path, WAVE_BUCKETS);
const peaks = await api.waveform(
path,
WAVE_BUCKETS,
range?.start ?? null,
range?.end ?? null,
);
// Ignore if the track changed while we were computing.
if (waveReqPath === path) {
if (waveReqPath === req) {
player.waveform = peaks;
player.waveformCoverage = 1;
}
} catch {
if (waveReqPath === path) player.waveform = [];
if (waveReqPath === req) player.waveform = [];
} finally {
// Either way the request is over; a failed local decode rests as dots
// rather than swelling forever at a waveform that will never come.
if (waveReqPath === path) player.wavePending = false;
if (waveReqPath === req) player.wavePending = false;
}
}
@@ -6314,15 +6548,29 @@ export function nextTrack(auto = false) {
playQueueIndex(next);
}
/** Engine time for a TRACK-time value. A set-recording slice's engine
* clock is the whole file's, so the slice offset is added exactly here —
* every transport call that hands the engine a time (seek, scratch,
* scratch release) must convert through this door. */
export function engineTime(secs) {
const seg = player.queue[player.queueIndex]?.segment;
return seg ? seg.start + Math.max(0, secs) : secs;
}
/** Seek in TRACK time; see `engineTime`. */
export function seekPlayer(secs) {
api.playerControl("seek", engineTime(secs));
}
export function prevTrack() {
// Restart the current track if we're more than 3s in.
if (player.position > 3) {
api.playerControl("seek", 0);
seekPlayer(0);
return;
}
const prev = player.queueIndex - 1;
if (prev >= 0) playQueueIndex(prev);
else api.playerControl("seek", 0);
else seekPlayer(0);
}
export function toggleShuffle() {
+106
View File
@@ -30,6 +30,7 @@
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
const album = $derived(page?.album ?? null);
@@ -132,18 +133,23 @@
* library keeps albums, not songs, so the owned tracklist has to be read
* from the matching local album rather than filtered out of a song list. */
let libraryTicks = $state(new Set());
/** The same rows with their paths kept, so a playlist add can point at the
* owned file instead of a stream placeholder. */
let libraryRows = $state([]);
let ticksFor = null;
$effect(() => {
const id = localAlbum?.id ?? null;
if (id === ticksFor) return;
ticksFor = id;
libraryTicks = new Set();
libraryRows = [];
if (id == null) return;
let stale = false;
api.albumTracks(id)
.then((rows) => {
if (!stale && ticksFor === id) {
libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
libraryRows = rows;
}
})
.catch(() => {});
@@ -152,6 +158,40 @@
};
});
// ---- add to playlist ----------------------------------------------------
/** Local files for this record by normalized title — a local page carries
* them in `page.libraryTracks`; an owned catalog page gets them from the
* ticks fetch above. */
const localByTitle = $derived.by(() => {
const m = new Map();
for (const lt of page?.libraryTracks ?? []) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
for (const lt of libraryRows) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
return m;
});
/** A playlist row for a track on this record. Owned tracks go in as the
* file on disk; the rest are placeholders that stream on click. */
function playlistRow(t) {
const local = localByTitle.get(norm(t.title || ""));
return {
title: t.title,
artist: album?.artist ?? null,
album: album?.title ?? null,
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
path: local?.path ?? null,
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
// Stream anchors, mirroring what playTrack hands the resolver. A
// local record's cover is a file path, not a URL — omit it there.
trackId: t.id ?? null,
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
};
}
// An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
@@ -804,6 +844,12 @@
<span class="badge" aria-hidden="true">{previewable}</span>
{/if}
</button>
<PlaylistMenu
compact
iconSize={17}
title="Add this record to a playlist"
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
/>
</div>
</div>
</div>
@@ -900,6 +946,14 @@
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
<span class="rowbtns">
{#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [playlistRow(t)]}
/>
{/if}
<button
class="mini-btn play"
disabled={locked}
@@ -1364,6 +1418,20 @@
color: rgba(255, 255, 255, 0.28);
cursor: default;
}
/* The add-to-playlist trigger wears the iconbtn look — the component
itself stays unstyled chrome (the Library rail set the precedent). */
.actions :global(.plm-trigger) {
display: grid;
place-items: center;
width: 2em;
height: 2em;
border-radius: 50%;
color: rgba(255, 255, 255, 0.85);
}
.actions :global(.plm-trigger:hover) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* Count for a record only some of whose tracks have a clip — the same
partial-availability report the artist page makes. */
.badge {
@@ -1745,10 +1813,14 @@
}
.trow:hover .tdur,
.trow:focus-within .tdur {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0;
pointer-events: none;
}
.trow.locked:hover .tdur {
opacity: 1;
pointer-events: auto;
}
.tag-unreleased {
@@ -1799,6 +1871,40 @@
.rowbtns .play {
color: var(--accent-hover);
}
/* Three buttons now reach past the duration column into the listeners
bar on counted records, so the cluster carries its own fade-in shim —
on the hover highlight it reads as the buttons simply taking over. */
.rowbtns {
padding-left: 14px;
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
}
.trow:focus-within {
background: var(--bg-highlight);
}
.rowbtns :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.rowbtns :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
display: flex;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
@media (max-width: 820px) {
.hero {
+121
View File
@@ -51,6 +51,7 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -5071,6 +5072,19 @@
<span class="track-album">{track.album}</span>
<span class="lis" class:unknown={track.listeners == null} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>{fmtListeners(track.listeners)}</span>
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: track.title,
artist: page.artist.name,
album: track.album ?? null,
duration_secs: track.duration_secs ?? null,
trackId: track.id ?? null,
artUrl: track.cover ?? null,
}]}
/>
<button
class="mini-get"
title="Save to library"
@@ -5425,6 +5439,19 @@
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
{#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: t.title,
artist: page.artist.name,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs ?? null,
trackId: t.id ?? null,
artUrl: t.cover ?? openAlbum?.cover ?? null,
}]}
/>
<button
class="mini-btn play"
title="Play"
@@ -7633,6 +7660,11 @@
}
.popular-row {
/* The anchor the absolute row actions were always written against —
without it the save button resolved against the page and its
reveal rules never existed, so it was dead UI. The 24px last
column is the space the overlaid actions land on. */
position: relative;
display: grid;
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px;
align-items: center;
@@ -7647,6 +7679,56 @@
background: var(--bg-highlight);
}
/* Hover slides the actions over the right end and the duration gets out
of the way — the album page's treatment. The listener count stays: the
list is ordered by it. */
.popular-row :global(.plm) {
position: absolute;
top: 50%;
right: 36px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.popular-row :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.popular-row :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.popular-row:hover .mini-get,
.popular-row:focus-within .mini-get,
.popular-row:hover :global(.plm),
.popular-row:focus-within :global(.plm) {
opacity: 1;
}
.popular-row:hover .tdur,
.popular-row:focus-within .tdur {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0;
pointer-events: none;
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
.popular-row.now .rank,
.popular-row.now .track-name {
color: var(--accent-hover);
@@ -8984,6 +9066,45 @@
.trow:hover .tdur,
.trow:focus-within .tdur {
opacity: 0;
pointer-events: none;
}
/* Add-to-playlist joins the expander row's overlay pair, one slot left of
Play; its menu is portaled, so hover alone would drop the cluster while
the menu is open — the aria-expanded rules hold it. */
.trow :global(.plm) {
position: absolute;
top: 50%;
right: 64px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.trow :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.trow :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.trow:hover :global(.plm),
.trow:focus-within :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-btn,
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
.trow.locked {
+38 -2
View File
@@ -44,6 +44,7 @@
import { accentFromImage } from "../lib/color.js";
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const TABS = ["All", "Songs", "Albums", "Artists", "In your library"];
@@ -1284,6 +1285,19 @@
<span>{fmtCount(listenersOf(t))}</span>
</span>
{/if}
<PlaylistMenu
compact
iconSize={14}
align="right"
tracks={() => [{
title: t.title,
artist: t.artist ?? null,
album: t.album ?? null,
duration_secs: t.duration_secs ?? null,
trackId: t.id ?? null,
artUrl: t.cover ?? null,
}]}
/>
<button
class="sv"
title="Save to library"
@@ -1928,7 +1942,7 @@
/* ---- song rows ------------------------------------------------------ */
.srow {
display: grid;
grid-template-columns: 20px 36px 1fr auto auto;
grid-template-columns: 20px 36px 1fr auto auto auto;
align-items: center;
gap: 11px;
padding: 7px 9px;
@@ -1936,7 +1950,7 @@
cursor: pointer;
}
.srow.has-count {
grid-template-columns: 20px 36px 1fr auto auto auto;
grid-template-columns: 20px 36px 1fr auto auto auto auto;
}
.srow:hover {
background: rgba(255, 255, 255, 0.055);
@@ -2023,6 +2037,28 @@
.sv:hover {
color: var(--accent-hover);
}
/* Add-to-playlist rides one cell left of Save, same reveal; the
aria-expanded rules hold the pair while its portaled menu is open. */
.srow :global(.plm) {
opacity: 0;
}
.srow :global(.plm-trigger) {
display: grid;
place-items: center;
color: var(--text-secondary);
}
.srow :global(.plm-trigger:hover) {
color: var(--accent-hover);
}
.srow:hover :global(.plm),
.srow:focus-within :global(.plm),
.srow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.srow:has(:global(.plm-trigger[aria-expanded="true"])) .sv {
opacity: 1;
}
.srow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: rgba(255, 255, 255, 0.055);
}
/* Listener weight: the bar's LENGTH is the count, curved by
src/lib/listenerBars.js so one runaway hit cannot squash the rest of the
+20
View File
@@ -2,6 +2,7 @@
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const uploads = $derived(
Object.values(network.uploads)
@@ -149,6 +150,15 @@
title="Show in folder"
onclick={() => api.showInFolder(d.local_path)}
><Icon name="folder-open" size={16} /></button>
<PlaylistMenu
compact
iconSize={15}
align="right"
tracks={() => [{
title: d.name.replace(/\.[^.]+$/, ""),
path: d.local_path,
}]}
/>
{/if}
</div>
{/each}
@@ -305,4 +315,14 @@
.reveal:hover {
opacity: 1;
}
.row :global(.plm-trigger) {
display: grid;
place-items: center;
opacity: 0.8;
color: inherit;
}
.row :global(.plm-trigger:hover) {
opacity: 1;
}
</style>
+791
View File
@@ -0,0 +1,791 @@
<script>
// The lyrics PAGE — variant C "Immersion" of research/lyrics-redesign,
// implemented to the user's dialed recipe verbatim (2026-08-18):
// 38px/1028px centred column, gap 0.76em, idle 42%, active ×1.20,
// hero dim 57% + blur 3px, accent wash 22%, duotone 56%, focus falloff
// 0.3px·d² (capped), sung lines dimmed harder, accent karaoke fill,
// scroll-to-seek, word timing when the source carries it. Hero drift and
// auto-hide chrome were tried in the mock and left off.
//
// A real page in the shell — glass bar above, player bar below, Esc goes
// back. It replaced the fullscreen overlay room.
import { untrack } from "svelte";
import {
player,
ui,
theme,
catalog,
getArt,
getArtMaster,
navBack,
resolveArtistSeedByName,
getCatalogArtistPage,
getCatalogArtistHeroSeed,
} from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
wordClockMs,
wordfill,
totalOffsetMs,
nudgeOffset,
setAutoSync,
} from "../lib/lyrics.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
// ---- the playing identity -------------------------------------------
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
const title = $derived(player.track?.title ?? "");
const artistName = $derived(
(player.track?.artist ?? "").trim()
|| String(player.track?.sub ?? "").split(" — ")[0].trim(),
);
const albumName = $derived(currentItem?.album ?? null);
// Chip artwork: a local file's embedded art, else the stream's cover URL.
let chipArt = $state(null);
$effect(() => {
const item = currentItem;
chipArt = null;
if (!item) return;
if (item.art) {
let stale = false;
getArt(item.art, 256).then((u) => { if (!stale) chipArt = u; }).catch(() => {});
return () => { stale = true; };
}
chipArt = item.artUrl ?? null;
});
/** A Deezer cover URL re-asked at hero size — a display upgrade of the
* SAME asset, not a different lookup. */
function coverAtHeroSize(url) {
return typeof url === "string"
? url.replace(/\/(\d+)x\1(-[0-9a-f-]+\.jpg)$/i, "/1000x1000$2")
: url;
}
// ---- the backdrop ladder --------------------------------------------
// Artist hero first — the staged artist page's fully-resolved set when
// the user has it open in this session, else the cached payload's
// banners/seed (the raw payload keeps the hero SEED; the resolved set
// only stages on the artist page itself). The record's cover stands in
// until a hero lands, and stays for artists without one — at MASTER
// resolution for local files, never the 256px chip thumb (a thumbnail
// stretched over the whole stage is what "low quality" looks like).
/** Every http hero candidate a page payload carries, resolved set first. */
function bannersOf(p) {
const list = p?.banners?.length
? p.banners
: p?.banner
? [p.banner]
: p?.hero_seed_banner
? [p.hero_seed_banner]
: [];
return list.filter((b) => typeof b === "string" && b.startsWith("http"));
}
let heroUrl = $state(null);
let heroGen = 0;
$effect(() => {
const name = artistName;
const gen = ++heroGen;
heroUrl = null;
if (!name) return;
// The artist page already staged this artist? Its resolved hero set
// is the best source and costs nothing.
const staged = catalog.artistPage;
if (
staged?.artist?.name
&& staged.artist.name.trim().toLowerCase() === name.trim().toLowerCase()
) {
const b = bannersOf(staged);
if (b.length) {
heroUrl = b[0];
return;
}
}
untrack(() => {
void (async () => {
try {
const seed = await resolveArtistSeedByName(name);
if (heroGen !== gen || seed?.id == null) return;
// The full payload's banners cover custom heroes; the
// HERO SEED is the standard case — the same image the
// artist page paints first.
const page = await getCatalogArtistPage(seed.id).catch(() => null);
if (heroGen !== gen) return;
const b = bannersOf(page);
if (b.length) {
heroUrl = b[0];
return;
}
const hs = await getCatalogArtistHeroSeed(seed.id);
if (heroGen !== gen) return;
const url =
typeof hs === "string"
? hs
: hs?.banner ?? hs?.url ?? hs?.src ?? null;
if (typeof url === "string" && url.startsWith("http")) heroUrl = url;
} catch {
// no banner — the cover backdrop stands
}
})();
});
});
// The stage's cover: the real pixels. Local files ask for the MASTER
// (the deliberate act getArt's doc demands of full-bleed surfaces);
// stream covers get re-asked at hero size.
let coverBig = $state(null);
$effect(() => {
const item = currentItem;
coverBig = null;
if (!item) return;
if (item.art) {
let stale = false;
getArtMaster(item.art).then((u) => { if (!stale) coverBig = u; }).catch(() => {});
return () => { stale = true; };
}
coverBig = coverAtHeroSize(item.artUrl ?? null);
});
const backdrop = $derived(heroUrl ?? coverBig ?? chipArt);
// ---- lyrics ----------------------------------------------------------
let shownKey = null;
$effect(() => {
const identity = lyricIdentity();
const key = identity
? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}`
: "";
if (key !== shownKey) shownKey = key;
// untrack: ensureLyrics reads and writes store fields synchronously —
// tracked, a failed fetch retriggers forever (the pane documents it).
untrack(() => {
noteTrackChange();
void ensureLyrics();
});
});
const lines = $derived(lyricsStore.status === "synced" ? lyricsStore.lines : []);
const active = $derived(
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
);
// ---- follow + scrub --------------------------------------------------
let feed = $state(null);
let holdUntil = 0;
let scrubIdx = $state(null);
let scrubbing = $state(false);
let chipPos = $state({ top: 0, left: 0 });
let settleTimer = 0;
let programmaticUntil = 0;
// The lens centres on the scrubbed line while the wheel is live,
// otherwise on the playing one.
const focusIdx = $derived(scrubIdx ?? active);
function centreOn(index, smooth = true) {
if (!feed || index < 0) return;
const el = feed.querySelector(`[data-line="${index}"]`);
if (!el) return;
const fr = feed.getBoundingClientRect();
const lr = el.getBoundingClientRect();
const top = feed.scrollTop + (lr.top + lr.height / 2) - (fr.top + fr.height / 2);
programmaticUntil = performance.now() + 800;
feed.scrollTo({
top: Math.max(0, top),
behavior: smooth && ui.windowActive ? "smooth" : "auto",
});
}
$effect(() => {
const index = active;
if (lyricsStore.status !== "synced") return;
if (performance.now() < holdUntil) return;
centreOn(index);
});
function fmtStamp(ms) {
const secs = Math.max(0, Math.round((ms + totalOffsetMs()) / 1000));
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function onUserScroll() {
if (performance.now() < programmaticUntil) return;
if (!feed || lyricsStore.status !== "synced") return;
holdUntil = performance.now() + 3500;
const fr = feed.getBoundingClientRect();
const y = fr.top + fr.height / 2;
let best = -1;
let bd = Infinity;
feed.querySelectorAll("[data-line]").forEach((el) => {
const r = el.getBoundingClientRect();
const d = Math.abs(r.top + r.height / 2 - y);
if (d < bd) { bd = d; best = Number(el.dataset.line); }
});
if (best < 0) return;
scrubIdx = best;
scrubbing = true;
chipPos = { top: Math.round(y - 16), left: Math.max(10, Math.round(fr.left + fr.width / 2 - 640)) };
clearTimeout(settleTimer);
settleTimer = setTimeout(settleScrub, 340);
}
function settleScrub() {
const idx = scrubIdx;
scrubbing = false;
scrubIdx = null;
if (idx == null) return;
const line = lines[idx];
if (!line) return;
// The jump: scrolling through the words IS seeking the song.
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
function seekTo(line) {
clearTimeout(settleTimer);
scrubbing = false;
scrubIdx = null;
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
/** Focus falloff, the recipe's quadratic ramp: min(d²/4, 8) × 0.3px. */
function dofBlur(i) {
const d = Math.abs(i - focusIdx);
return Math.min((d * d) / 4, 8);
}
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 onkeydown(e) {
if (e.key === "Escape") void navBack();
}
// The room follows the music out: nothing playing, nothing to show.
$effect(() => {
if (ui.view === "lyrics" && !player.track) void navBack();
});
</script>
<svelte:window onkeydown={ui.view === "lyrics" ? onkeydown : undefined} />
<div class="lyrpage" style="--wash-accent: {theme.accent}">
<div class="full" aria-hidden="true">
{#if backdrop}
{#key backdrop}
<img src={backdrop} alt="" draggable="false" />
{/key}
<span class="tint"></span>
{/if}
<span class="grade"></span>
</div>
{#if title}
<div class="chip">
{#if chipArt}
<img src={chipArt} alt="" draggable="false" />
{:else}
<span class="chip-fallback"><Icon name="music" size={18} /></span>
{/if}
<div>
<div class="t">{title}</div>
<div class="a">{artistName}{albumName ? ` — ${albumName}` : ""}</div>
</div>
</div>
{/if}
{#if lyricsStore.status === "synced"}
<div class="tools">
<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>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="feed"
bind:this={feed}
onwheel={onUserScroll}
ontouchmove={onUserScroll}
onscroll={onUserScroll}
>
<div class="pad"></div>
{#each lines as line, index (index)}
{#if !line.text}
<button
class="line gapdots"
class:on={index === active}
class:past={index < active}
data-line={index}
style="--dblur: {dofBlur(index)}"
onclick={() => seekTo(line)}
title="Play from here"
><i></i><i></i><i></i></button>
{:else}
<button
class="line"
class:on={index === active}
class:past={index < active}
class:scrub={index === scrubIdx}
data-line={index}
style="--dblur: {dofBlur(index)}"
onclick={() => seekTo(line)}
title="Play from here"
>
{#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 — never invented (the Noctis rule). -->
<span
class="wline"
use:wordfill={{
line,
ms: wordClockMs(line, player.position ?? 0),
endBound: lines[index + 1]?.time_ms
?? line.words[line.words.length - 1].time_ms + 2500,
}}
>
{#each line.words as word, wi (wi)}
<span class="fw">{word.text}</span>
{/each}
</span>
{:else}
<!-- Line mode: the active line is a SOLID accent
fill (.line.on) — the interpolated sweep is
gone (Kyle 2026-08-19: mid-word splits read
as glitches; only REAL word times may move
through letters, and those live behind the
experimental toggle above). -->
{line.text}
{/if}
</button>
{/if}
{/each}
<div class="pad"></div>
</div>
{#if scrubbing && scrubIdx != null && lines[scrubIdx]}
<div class="scrubchip" style="top: {chipPos.top}px; left: {chipPos.left}px">
{fmtStamp(lines[scrubIdx].time_ms)}
</div>
{/if}
{:else if lyricsStore.status === "plain"}
<div class="feed plain">
<div class="pad"></div>
{#each (lyricsStore.plain ?? "").split("\n") as pline, i (i)}
<p class="line stat">{pline.trim() ? pline : "· · ·"}</p>
{/each}
<div class="pad"></div>
</div>
<div class="statechip">not synced — the rail's lyrics pane can time them</div>
{:else if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
<div class="statebox">
<span class="ring"></span>
</div>
{:else if lyricsStore.status === "instrumental"}
<div class="statebox">
<Icon name="music" size={30} />
<p>Instrumental — sit back.</p>
</div>
{:else}
<div class="statebox">
<Icon name="music" size={30} />
<p>No lyrics found for this one.</p>
</div>
{/if}
</div>
<style>
/* The stage fills the pane, edge to edge, and never scrolls itself —
the feed inside it does. Follows the Library/discography exception:
the backdrop must begin at the very pane top, behind the glass.
Height arithmetic: 100% resolves against the pane's CONTENT box —
both --pane-pad bands already subtracted — while the glass bar
cancels the top one, so a bare 100% ends 2×pad short and the hero
stops in a black band above the player bar (Kyle, 2026-08-19).
Reclaim both pads and bleed the bottom one away. */
.lyrpage {
position: relative;
margin: 0 -28px calc(-1 * var(--pane-pad, 24px));
height: calc(100% + 2 * var(--pane-pad, 24px));
overflow: hidden;
isolation: isolate;
/* The type scale below is container-relative: the page is the
container (its height is an explicit calc, so `size` is legal). */
container-type: size;
}
/* ---- backdrop: hero (or cover) in the record's own duotone ---- */
.full {
position: absolute;
inset: -20px;
z-index: 0;
}
.full img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 24%;
/* recipe: hero-dim 57%, hero-blur 3px, duotone base */
filter: grayscale(1) brightness(0.43) blur(3px) contrast(1.05);
transform: scale(1.04);
}
.full .tint {
position: absolute;
inset: 0;
/* recipe: duotone 56%, tone pulled toward neutral so it reads like a
sleeve, not paint */
background: color-mix(in srgb, var(--wash-accent) 80%, #77706e);
mix-blend-mode: color;
opacity: 0.56;
}
.full .grade {
position: absolute;
inset: 0;
background:
radial-gradient(120% 90% at 50% 108%, rgba(10, 10, 10, 0.9), rgba(10, 10, 10, 0.42) 55%, rgba(10, 10, 10, 0.28)),
radial-gradient(80% 60% at 50% 0%, color-mix(in srgb, var(--wash-accent) 22%, transparent), transparent 62%);
}
/* ---- identity chip, top centre under the glass ---- */
.chip {
position: absolute;
top: 72px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
z-index: 3;
background: rgba(12, 12, 12, 0.5);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 9px 16px 9px 9px;
max-width: min(60%, 560px);
}
.chip img,
.chip-fallback {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
flex: none;
display: grid;
place-items: center;
background: #232323;
color: var(--text-muted);
}
.chip .t {
font-size: 14.5px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip .a {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- source + nudge, top right under the glass ---- */
.tools {
position: absolute;
top: calc(var(--bar-h, 46px) + 12px);
right: 24px;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
}
.src {
font-size: 10px;
letter-spacing: 0.12em;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
}
.autochip {
font-size: 11px;
font-weight: 700;
color: var(--accent-hover);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 999px;
padding: 3px 9px;
}
.nudge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
color: var(--text-secondary);
}
.nudge .tool {
color: var(--text-secondary);
padding: 0 3px;
}
.nudge .tool:hover {
color: var(--text-primary);
}
.nudge .off.on {
color: var(--accent-hover);
}
/* ---- the feed ---- */
.feed {
position: absolute;
inset: 0;
z-index: 1;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
align-items: center;
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
}
.feed::-webkit-scrollbar {
display: none;
}
.feed .pad {
flex: none;
height: 52vh;
}
.line {
position: relative;
flex: none;
/* recipe: 38px / 1028px / 0.76em gap, centred — the 38 now the CAP
of a container-relative scale, so a small window shrinks the type
with it (Kyle, 2026-08-19) and a full-size pane is pixel-identical
to the dialed recipe. Coefficients: 3.5cqi ≈ 38px at the pane
width where the 1028px feed first compresses (~1086px incl the
reclaimed side pads); 5.3cqb ≈ 38px at the ~717px pane height of
the dialed layout; the SMALLER axis governs, floored for tiny
windows. Everything inside the line is em-based and follows. */
font-size: clamp(20px, min(3.5cqi, 5.3cqb), 38px);
line-height: 1.28;
font-weight: 700;
max-width: min(1028px, 86%);
margin: 0.38em 0;
text-align: center;
/* recipe: idle 42% */
color: color-mix(in srgb, var(--text-primary) 42%, transparent);
transform-origin: center;
transition:
color 260ms ease,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 260ms ease,
filter 260ms ease;
/* recipe: focus falloff 0.3px × d²/4, capped */
filter: blur(calc(var(--dblur, 1) * 0.3px));
cursor: pointer;
}
/* recipe: sung lines dim harder than unsung */
.line.past {
opacity: 0.3;
}
.line.on {
/* recipe: active ×1.20; SOLID accent letters — the whole line is
the highlight (word mode's children clip their own fills and
ignore this color). */
transform: scale(1.2);
color: var(--accent-hover);
filter: none;
opacity: 1;
}
.line.scrub {
color: var(--text-primary);
filter: none;
}
.line:not(.on):hover {
color: color-mix(in srgb, var(--text-primary) 80%, transparent);
filter: none;
}
.line:not(.on):hover::before {
content: "▶";
position: absolute;
left: -30px;
top: 50%;
translate: 0 -50%;
font-size: 0.38em;
color: var(--accent-hover);
opacity: 0.85;
}
/* accent karaoke fill: ONE glyph raster — the active line's text is
painted by a gradient clipped to the glyphs, sung accent left of the
feathered boundary, unsung white right of it. (Two stacked text layers
fringe against each other; this cannot.)
NAMESPACED on pain of death: @property registrations are DOCUMENT
GLOBAL — registering bare `--p` as a percentage broke the artist
page's scroll variable of the same name (its unitless writes stopped
parsing and the whole hero pipeline collapsed). */
@property --lyr-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
/* karaoke word fill (the wordfill mock's dialed variant A): each word is
its OWN gradient-clip raster — accent left of its feathered boundary,
unsung white right of it — wiping across the word's real sung span.
Feather ±8%, mock-dialed. --lyr-p is per-element (inherits: false). */
.wline .fw {
--lyr-p: -8%;
background-image: linear-gradient(
90deg,
var(--accent-hover) calc(var(--lyr-p) - 8%),
var(--text-primary) calc(var(--lyr-p) + 8%)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.wline .fw + .fw {
margin-left: 0.28em;
}
/* instrumental gap: three breathing dots */
.line.gapdots {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
height: 1.28em;
}
.line.gapdots i {
width: 0.32em;
height: 0.32em;
border-radius: 50%;
background: color-mix(in srgb, var(--text-primary) 40%, transparent);
}
.line.gapdots.on i {
animation: dotpulse 1.4s ease-in-out infinite;
}
.line.gapdots.on i:nth-child(2) {
animation-delay: 0.22s;
}
.line.gapdots.on i:nth-child(3) {
animation-delay: 0.44s;
}
@keyframes dotpulse {
0%, 100% {
transform: scale(1);
background: color-mix(in srgb, var(--text-primary) 45%, transparent);
}
50% {
transform: scale(1.55);
background: var(--text-primary);
}
}
@media (prefers-reduced-motion: reduce) {
.line.gapdots.on i {
animation: none;
background: var(--text-primary);
}
}
/* scrub time chip, pinned to the focus band */
.scrubchip {
position: fixed;
z-index: 60;
display: inline-flex;
align-items: center;
gap: 7px;
background: rgba(12, 12, 12, 0.62);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 5px 12px;
font-size: 12.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.scrubchip::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
/* ---- non-synced states ---- */
.feed.plain .line.stat {
cursor: default;
filter: none;
}
.statechip {
position: absolute;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
font-size: 11.5px;
color: var(--text-secondary);
background: rgba(12, 12, 12, 0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 5px 12px;
}
.statebox {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 14px;
}
.ring {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid rgba(255, 255, 255, 0.16);
border-top-color: var(--accent-hover);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+596 -272
View File
File diff suppressed because it is too large. Load diff
+285 -101
View File
@@ -1,7 +1,7 @@
<script>
// 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).
// the import doors (playlist file, folder, pasted tracklist, YouTube set).
import {
playlistStore,
refreshPlaylists,
@@ -10,6 +10,8 @@
playAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
import { fade } from "svelte/transition";
import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
@@ -34,6 +36,27 @@
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
// The stat row's numbers — derived from what the store already carries.
const totals = $derived.by(() => {
let tracks = 0;
let secs = 0;
for (const p of playlistStore.playlists) {
tracks += p.track_count ?? 0;
secs += p.duration_secs ?? 0;
}
return { tracks, secs };
});
// The ambience wash borrows the first pinned playlist's cover, else the
// first cover that has resolved. Covers arrive lazily, so this settles a
// beat after the cards do — the keyed fade below makes that a dissolve.
const ambUrl = $derived.by(() => {
const pick =
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
return pick ? playlistStore.covers[pick.id] : null;
});
async function newPlaylist() {
if (creating) return;
creating = true;
@@ -90,6 +113,23 @@
importOpen = true;
}
// A finished background listen: the chip's "review" click lands here —
// consume the waiting draft into the ordinary review modal.
$effect(() => {
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
importDraft = takeScanDraft();
importMode = "file";
importOpen = true;
}
});
function importFromYoutube() {
newMenuOpen = false;
importDraft = null;
importMode = "youtube";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
@@ -102,21 +142,49 @@
const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return;
const playable = rows.filter((r) => r.path && !r.missing);
// Present local files plus stream rows — streams queue as pending
// items that resolve when reached, same as the playlist page's Play.
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
if (!playable.length) {
open(pl); // nothing playable: the page explains why
return;
}
// Set-recording slices: each is bounded by the NEXT stamped row in
// the full order (the playlist page derives the same map).
const segmentEnds = new Map();
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (r.kind !== "segment" || r.start_ms == null) continue;
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
segmentEnds.set(r.id, next ? next.start_ms : null);
}
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,
})),
playable.map((r) => {
const local = r.path && !r.missing;
const tid = Number(r.track_id);
const endMs = segmentEnds.get(r.id);
const segment =
r.kind === "segment" && local && r.start_ms != null
? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
: null;
return {
path: local ? r.path : null,
needsResolve: !local,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs:
r.duration_secs ??
(segment && segment.end != null
? Math.round(segment.end - segment.start)
: null),
art_path: local ? r.art_path : null,
artUrl: local ? null : (r.art_url ?? null),
trackId: Number.isFinite(tid) ? tid : null,
plItemId: r.id,
segment,
};
}),
pl.name,
0,
);
@@ -186,53 +254,85 @@
<svelte:window onclick={pageClick} />
<div class="page">
<header>
<div class="head-text">
{#if ambUrl}
<!-- The Library's ambience recipe: a blurred cover pinned behind the
glass, faded out before the cards. Keyed so a cover change
CROSSFADES — background-image is not an animatable property, so
the outgoing wash survives as its own element while the incoming
one fades up over it. -->
{#key ambUrl}
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
{/key}
<div class="ambience-glow"></div>
{/if}
<!-- The Library's own header shape: h1 + tools on one line, the stat row
under them. An empty shelf keeps the same header — the Import door
matters most at the moment there is nothing here. -->
<header class="hero">
<div class="hero-top">
<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} />
<div class="tools">
<div class="menu-wrap">
<button
class="tool"
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>
<button class="mi" onclick={importFromYoutube}>
<Icon name="link" size={15} />
YouTube DJ set…
<span class="hint">tracks from chapters, description or comments</span>
</button>
</div>
{/if}
</div>
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</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>
</div>
</div>
{#if playlistStore.playlists.length}
<div class="stats">
<span class="stat">
<b>{playlistStore.playlists.length}</b>
<i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
</span>
<span class="stat-div"></span>
<span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
{#if totals.secs}
<span class="stat-div"></span>
<span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
{/if}
{#if pinned.length}
<span class="stat-div"></span>
<span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
{/if}
</div>
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button>
</div>
{/if}
</header>
{#if playlistStore.playlists.length === 0}
@@ -353,63 +453,147 @@
/>
<style>
/* The Library page's own frame: the blurred ambience pins behind the
glass bar, so the page starts at the very top of the pane and carries
its own inset instead of taking padding-top from the BAR_VIEWS rule
(the Library exception, documented at the set in App.svelte). The
isolation keeps the z-indexed washes inside this page's own stacking
context — without it they escape past the transparent pane and paint
behind the app's opaque black. */
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.sub {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted);
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
position: relative;
isolation: isolate;
--pad-top: calc(var(--bar-h, 46px) + 14px);
padding: var(--pad-top) 0 40px;
}
.btn {
/* ------------------------------------------------------------ ambience
The Library's recipe at this page's colour: blurred cover, faded out
before the cards; z-index 0 lies OVER the in-flow header (that is the
approved look — the h1 carries a text-shadow for it), pointer-events
none so nothing under it goes dead. */
.ambience {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 430px;
z-index: 0;
pointer-events: none;
background-size: cover;
background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.2;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
mask-image: linear-gradient(180deg, #000 35%, transparent);
}
.ambience-glow {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 300px;
z-index: 0;
pointer-events: none;
background: radial-gradient(
640px 260px at 14% 0%,
color-mix(in srgb, var(--accent) 13%, transparent),
transparent 70%
);
}
/* ---------------------------------------------------------------- hero */
.hero {
padding-top: 4px;
}
.hero-top {
display: flex;
align-items: center;
gap: 16px;
}
h1 {
font-size: 31px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
}
.tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* The Library's tool button: translucent elevated glass, one height. */
.tool {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
transition:
background 0.14s,
color 0.14s,
transform 0.14s;
}
.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);
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.btn.ghost:hover {
background: var(--bg-press);
.tool:hover:not(:disabled) {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.btn:disabled {
opacity: 0.55;
.tool:disabled {
opacity: 0.5;
cursor: default;
}
.tool.primary {
background: var(--accent);
border-color: transparent;
color: #08080a;
}
.tool.primary:hover:not(:disabled) {
background: var(--accent-hover);
color: #08080a;
}
/* The stat row — the Library's value/label idiom for the collection. */
.stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 15px 0 20px;
}
.stat {
display: inline-flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
}
.stat b {
font-size: 15.5px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat i {
font-style: normal;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-muted);
}
.stat-div {
width: 1px;
height: 17px;
flex: none;
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
}
.menu-wrap {
+183
View File
@@ -1,10 +1,71 @@
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let settings = $state(null);
let saving = $state(false);
// Lyric alignment model (Tier B word timing) — downloaded only from the
// explicit click below, per the preparation-worker consent charter.
let lyricModel = $state(null);
let modelBusy = $state(false);
let modelPct = $state(0);
let modelError = $state("");
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
// row below, shown only once the aligner model above exists.
let sepModel = $state(null);
let sepBusy = $state(false);
let sepPct = $state(0);
let sepError = $state("");
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
$effect(() => {
let unlisten = null;
let unlistenSep = null;
void import("@tauri-apps/api/event").then(({ listen }) => {
listen("lyrics:model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlisten = u));
listen("lyrics:sep-model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlistenSep = u));
});
return () => {
unlisten?.();
unlistenSep?.();
};
});
async function downloadModel() {
if (modelBusy) return;
modelBusy = true;
modelError = "";
modelPct = 0;
try {
await api.lyricModelDownload();
lyricModel = await api.lyricModelStatus();
} catch (e) {
modelError = String(e).slice(0, 160);
} finally {
modelBusy = false;
}
}
async function downloadSepModel() {
if (sepBusy) return;
sepBusy = true;
sepError = "";
sepPct = 0;
try {
await api.vocalSepDownload();
sepModel = await api.vocalSepStatus();
} catch (e) {
sepError = String(e).slice(0, 160);
} finally {
sepBusy = false;
}
}
let savedFlash = $state(false);
let saveError = $state("");
let refreshingArtists = $state(false);
@@ -44,6 +105,15 @@
}
}
async function setWordTiming(next) {
if (next !== settings.word_timing) {
await commit({ wordTiming: next });
// Applies live: surfaces stop/start rendering word markup
// reactively, and turning it on asks for the current track.
setWordFill(next);
}
}
async function chooseDownloadDir() {
const dir = await api.pickFolder();
if (dir) await commit({ downloadDir: dir });
@@ -250,6 +320,79 @@
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
<section>
<h2>Lyrics</h2>
<div class="row">
<div class="label">
<div class="name">Word-by-word fill <span class="exp-tag">Experimental</span></div>
<div class="hint">
Fills each word as it is sung, karaoke-style. Still experimental: timing
can drift on dense or echo-heavy songs. Off keeps the reliable
line-by-line sync everywhere and pauses the background word analysis.
</div>
</div>
<select
class="device-select"
value={settings.word_timing ? "on" : "off"}
disabled={saving}
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
>
<option value="off">Off (recommended for now)</option>
<option value="on">On</option>
</select>
</div>
{#if settings.word_timing}
<div class="row">
<div class="label">
<div class="name">Precise word timing</div>
<div class="hint">
Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that
listens to each lyric line and times every word against the actual singing,
in most languages. Runs entirely on this computer, in the background, never
against the playing track. Without it, word timing stays a lighter estimate.
Already-timed songs re-prepare automatically the next time you open their
lyrics.
</div>
</div>
{#if lyricModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
</button>
{/if}
</div>
{#if modelError}
<p class="muted small">Download failed: {modelError}</p>
{/if}
{#if lyricModel?.present}
<div class="row">
<div class="label">
<div class="name">Vocal isolation</div>
<div class="hint">
Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from
the Ultimate Vocal Remover project) that lifts the singing away from
the band before words are timed — noticeably steadier timing on dense
mixes. Same rules as above: runs on this computer, in the background,
never against the playing track. Timed songs re-prepare the next time
you open their lyrics.
</div>
</div>
{#if sepModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
</button>
{/if}
</div>
{#if sepError}
<p class="muted small">Download failed: {sepError}</p>
{/if}
{/if}
{/if}
</section>
<section>
<h2>Streaming</h2>
<div class="row">
@@ -736,4 +879,44 @@
.save-error {
color: #f15e6c;
}
.model-btn {
flex: none;
padding: 8px 16px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.model-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.model-btn:disabled {
opacity: 0.7;
cursor: default;
}
.exp-tag {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
vertical-align: 2px;
}
.model-ok {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: #3ecf7e;
font-size: 13px;
font-weight: 700;
}
</style>