playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+911
View File
@@ -0,0 +1,911 @@
<script>
// The Now Playing lyrics pane: synced lines that follow playback with the
// active line held at SEAT, click-to-seek, a sync nudge, a candidate
// picker and a paste door for tracks nothing automatic can cover. Also
// rendered by the fullscreen overlay in `big` mode — one component, two
// type scales.
import { player, ui, playLocalFile } from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
activeWordIndex,
totalOffsetMs,
nudgeOffset,
setAutoSync,
searchCandidates,
closeCandidates,
chooseCandidate,
savePastedLyrics,
resetLyrics,
openSyncEditor,
} from "../lib/lyrics.svelte.js";
import { lyricLinesOf } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { untrack } from "svelte";
let { big = false } = $props();
let box = $state(null);
let pasting = $state(false);
let pasteText = $state("");
let menuOpen = $state(false);
// While the user is scrolling the box themselves, auto-follow stands
// down; it resumes a few seconds after the wheel goes quiet.
let holdUntil = 0;
// Load on mount and again whenever the playing track changes while the
// pane is visible. noteTrackChange clears stale lines synchronously, and
// an open paste box or menu goes with them — a paste typed for one song
// must never be saved under the identity of the next.
let shownKey = null;
$effect(() => {
const identity = lyricIdentity();
const key = identity ? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}` : "";
if (key !== shownKey) {
shownKey = key;
pasting = false;
pasteText = "";
menuOpen = false;
}
// untrack: ensureLyrics reads AND writes lyricsStore.status/key
// synchronously, so tracked it makes a failed fetch retrigger this
// effect forever (error → loading → error…) — the exact boundary
// NowPlaying's loadDiscovery documents. The effect keys off the
// playing IDENTITY alone.
untrack(() => {
noteTrackChange();
void ensureLyrics();
});
});
const active = $derived(
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
);
// ---- instrumental (offline vocal reduction) ----
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
const canInstrumental = $derived(
!!currentItem?.path && !currentItem?.stream && lyricsStore.status !== "loading",
);
let instrumentalBusy = $state(false);
let instrumentalNote = $state("");
async function playInstrumental() {
const item = currentItem;
if (instrumentalBusy || !item?.path || item.stream) return;
instrumentalBusy = true;
instrumentalNote = "";
try {
const res = await api.lyricsPrepareInstrumental(item.path);
// The prepare can take seconds; if the queue moved on meanwhile,
// starting the OLD track's instrumental would hijack whatever is
// playing now.
const current = player.queue?.[player.queueIndex] ?? null;
if (current?.path !== item.path) {
instrumentalNote = "The track changed while preparing — ready under ⋯ next time.";
return;
}
menuOpen = false;
const artist =
(item.artist ?? "").trim() || String(item.sub ?? "").split(" — ")[0].trim();
// The ORIGINAL title on purpose: the lyric identity stays with the
// recording, so the words keep following the instrumental.
await playLocalFile(res.path, item.title, `${artist} — instrumental`, item.art ?? null);
} catch (e) {
instrumentalNote = String(e).replace(/^Error:\s*/, "").slice(0, 140);
} finally {
instrumentalBusy = false;
}
}
// Where the active line is held inside the box: 0 = its top edge, 1 = its
// bottom. Above centre on purpose — dead centre spends half the box on
// words already sung, and the lead spacer it needed marooned a song's
// first lines below a screenful of nothing. The stylesheet's .tail-space
// is this value's complement, so the LAST line still reaches the same
// seat as every other one.
const SEAT = 0.42;
// Follow the active line, held at SEAT, unless the user is exploring.
$effect(() => {
const index = active;
if (index < 0 || !box || lyricsStore.status !== "synced") return;
if (performance.now() < holdUntil) return;
const el = box.querySelector(`[data-line="${index}"]`);
if (!el) return;
// Measured against the BOX, not offsetTop. offsetTop counts from
// offsetParent, which here is .pane (its panein keyframes animate a
// transform, so Chromium hands it the role) — the toolbar's height
// rode along in every target, seating the line ~30px above wherever
// the number said. Rects and scrollTop are origin-independent, so
// SEAT means what it reads.
const top = box.scrollTop + (el.getBoundingClientRect().top - box.getBoundingClientRect().top);
const target = top - box.clientHeight * SEAT + el.offsetHeight / 2;
box.scrollTo({
top: Math.max(0, target),
behavior: ui.windowActive ? "smooth" : "auto",
});
});
function userScrolled() {
holdUntil = performance.now() + 3500;
}
function seekTo(line) {
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
async function submitPaste() {
const ok = await savePastedLyrics(pasteText);
if (ok) {
pasting = false;
pasteText = "";
}
}
const SOURCE_LABEL = {
sidecar: "from a lyrics file",
embedded: "from the audio file",
lrclib: "via LRCLIB",
amll: "via AMLL DB · word-synced",
picked: "picked on LRCLIB",
user: "pasted by you",
};
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
</script>
<div class="lyr" class:big>
{#if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
<div class="shimmer" aria-hidden="true">
<span style="width: 72%"></span>
<span style="width: 58%"></span>
<span style="width: 81%"></span>
<span style="width: 47%"></span>
</div>
{:else if lyricsStore.candidates !== null}
<div class="picker">
<div class="pick-head">
<span>Choose the right lyrics</span>
<button class="tool" title="Close" onclick={closeCandidates}>
<Icon name="x" size={14} />
</button>
</div>
{#if lyricsStore.candidatesLoading}
<p class="note">Searching LRCLIB…</p>
{:else if lyricsStore.candidates.length === 0}
<p class="note">Nothing found for this track name.</p>
{:else}
<div class="cands">
{#each lyricsStore.candidates as c (c.id)}
<button class="cand" onclick={() => chooseCandidate(c.id)}>
<span class="c-main">
<span class="c-t">{c.title}</span>
<span class="c-a">{c.artist}{c.album ? ` — ${c.album}` : ""}</span>
</span>
{#if c.synced}<span class="c-tag">synced</span>{/if}
{#if c.instrumental}<span class="c-tag inst">instrumental</span>{/if}
<span class="c-d">{fmtDur(c.durationSecs)}</span>
</button>
{/each}
</div>
{/if}
</div>
{:else if pasting}
<div class="paste">
<p class="note">
Paste lyrics — plain text, or LRC with <span class="mono">[mm:ss]</span> timestamps
for synced display.
</p>
<textarea bind:value={pasteText} spellcheck="false" placeholder="[00:12.00] First line…"></textarea>
<div class="paste-actions">
<button class="pbtn ghost" onclick={() => (pasting = false)}>Cancel</button>
<button class="pbtn" onclick={submitPaste} disabled={!pasteText.trim()}>Save</button>
</div>
</div>
{:else if lyricsStore.status === "synced"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""}</span>
{#if lyricsStore.autoOffsetMs !== 0}
<button
class="autochip"
title="Timing auto-corrected from this file's extra lead-in — click to turn off"
onclick={() => setAutoSync(false)}
>
auto {lyricsStore.autoOffsetMs > 0 ? "+" : ""}{(lyricsStore.autoOffsetMs / 1000).toFixed(1)}s
</button>
{/if}
<span class="nudge" title="Shift lyric timing">
<button class="tool" title="Lyrics earlier" onclick={() => nudgeOffset(-500)}>−</button>
<span class="off" class:on={lyricsStore.offsetMs !== 0}>
{lyricsStore.offsetMs === 0
? "sync"
: `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`}
</span>
<button class="tool" title="Lyrics later" onclick={() => nudgeOffset(500)}>+</button>
</span>
{@render tools()}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="lines"
bind:this={box}
onwheel={userScrolled}
ontouchmove={userScrolled}
>
<div class="lead-space"></div>
{#each lyricsStore.lines as line, index (index)}
<button
class="line"
class:on={index === active}
class:past={index < active}
class:gap={!line.text}
data-line={index}
onclick={() => seekTo(line)}
title="Play from here"
>
{#if index === active && line.words?.length}
<!-- Real word timing from the source: crisp per-word.
This is the ONLY karaoke mode. A line-level source
gets the whole-line highlight and nothing more —
a sweep interpolated across the line's window
tracked the layout, not the voice (it crawled
through instrumental gaps), so it read as broken.
Word times are carried, never invented (the
Noctis rule). -->
{@const sungTo = activeWordIndex(line, player.position ?? 0)}
<span class="wline">
{#each line.words as word, wi (wi)}
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
{/each}
</span>
{:else}
{line.text || "· · ·"}
{/if}
</button>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "plain"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""} · not synced</span>
{@render tools()}
</div>
<button
class="syncnow"
title="Play the song once and tap each line as it begins"
onclick={() => openSyncEditor(lyricLinesOf(lyricsStore.plain))}
>
<Icon name="clock" size={14} /> Sync these lyrics
</button>
<!-- The same box, the same type, the same fade as the synced list —
a lyric sheet is a lyric sheet whether or not anything timed it.
Only the parts that need a clock are absent: no line is lit, and
none of them seek. -->
<div class="lines plain" bind:this={box}>
<div class="lead-space"></div>
{#each lyricsStore.plain.split("\n") as pline, i (i)}
<p class="line" class:gap={!pline.trim()}>{pline.trim() ? pline : "· · ·"}</p>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "instrumental"}
<div class="statebox">
<span class="state-glyph"><Icon name="music" size={26} /></span>
<p class="state-t">Instrumental</p>
<p class="state-s">Sit back — there are no words to follow.</p>
</div>
{:else if lyricsStore.status === "error"}
<div class="statebox">
<p class="state-t">Couldn’t look up lyrics</p>
<p class="state-s">Something failed along the way.</p>
<button class="pbtn" onclick={() => ensureLyrics(true)}>Try again</button>
</div>
{:else}
<div class="statebox">
<p class="state-t">No lyrics found</p>
<p class="state-s">
Nothing local and no LRCLIB match for this exact recording.
</p>
<div class="state-actions">
<button class="pbtn" onclick={searchCandidates}>
<Icon name="search" size={13} /> Search LRCLIB
</button>
<button class="pbtn ghost" onclick={() => (pasting = true)}>
<Icon name="file-text" size={13} /> Paste lyrics
</button>
</div>
</div>
{/if}
</div>
{#snippet tools()}
<span class="tools">
{#if !big}
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
<Icon name="expand" size={13} />
</button>
{/if}
<span class="tool-menu">
<button
class="tool"
title="Lyrics options"
onclick={(e) => {
e.stopPropagation();
menuOpen = !menuOpen;
}}
>
<Icon name="more-horizontal" size={15} />
</button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if canInstrumental}
<button class="mi" onclick={playInstrumental} disabled={instrumentalBusy}>
<Icon name="music" size={14} />
{instrumentalBusy ? "Preparing instrumental…" : "Play with reduced vocals"}
<span class="mi-hint">beta</span>
</button>
{#if instrumentalNote}
<p class="mi-note">{instrumentalNote}</p>
{/if}
<div class="mi-rule"></div>
{/if}
{#if lyricsStore.autoSyncOff}
<button
class="mi"
onclick={() => {
menuOpen = false;
setAutoSync(true);
}}
><Icon name="refresh" size={14} /> Re-enable auto-sync</button>
{/if}
{#if lyricsStore.status === "synced"}
<button
class="mi"
onclick={() => {
menuOpen = false;
openSyncEditor(lyricsStore.lines);
}}
><Icon name="clock" size={14} /> Re-time by hand</button>
{/if}
<button
class="mi"
onclick={() => {
menuOpen = false;
searchCandidates();
}}
><Icon name="search" size={14} /> Wrong lyrics? Search again</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
pasting = true;
}}
><Icon name="file-text" size={14} /> Paste your own</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
resetLyrics();
}}
><Icon name="refresh" size={14} /> Forget and look again</button>
</div>
{/if}
</span>
</span>
{/snippet}
<svelte:window onclick={menuOpen ? () => (menuOpen = false) : undefined} />
<style>
.lyr {
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── loading shimmer ── */
.shimmer {
display: flex;
flex-direction: column;
gap: 13px;
padding: 18px 4px;
}
.shimmer span {
height: 13px;
border-radius: 7px;
background: linear-gradient(
100deg,
var(--bg-highlight) 35%,
var(--bg-press) 50%,
var(--bg-highlight) 65%
);
background-size: 240% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
/* ── toolbar ── */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 2px 8px;
min-height: 30px;
}
.src {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* The label owns the slack: everything after it sits at the right. */
margin-right: auto;
}
.nudge {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--bg-highlight);
border-radius: 12px;
padding: 1px 4px;
flex: none;
}
.nudge .off {
min-width: 38px;
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.nudge .off.on {
color: var(--accent-hover);
}
.tools {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.tool {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.tool:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.tool-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 220px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 60;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.mi:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
/* ── synced lines ── */
.lines {
overflow-y: auto;
max-height: 46vh;
min-height: 180px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
}
.lines::-webkit-scrollbar {
width: 0;
display: none;
}
/* Just a breath above the first line — enough that the words clear the
toolbar's mask fade, not so much that a song opens on an empty box.
The tail is SEAT's complement (1 - 0.42), which is what lets the final
line scroll up to the same seat the rest of them ride at. */
.lead-space {
height: 7%;
flex: none;
}
.tail-space {
height: 58%;
flex: none;
}
.line {
display: block;
width: 100%;
text-align: left;
padding: 5px 6px;
border-radius: 7px;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
color: var(--text-muted);
transition:
color 0.25s ease,
transform 0.25s ease,
opacity 0.25s ease;
transform-origin: left center;
white-space: pre-wrap;
}
.line:hover {
color: var(--text-secondary);
background: var(--bg-highlight);
}
.line.past {
color: var(--text-secondary);
opacity: 0.55;
}
.line.on {
color: var(--text-primary);
transform: scale(1.03);
opacity: 1;
}
.line.gap {
color: var(--text-muted);
opacity: 0.4;
font-size: 12px;
letter-spacing: 0.3em;
}
.line.gap.on {
color: var(--accent-hover);
opacity: 0.8;
}
/* ── karaoke rendering inside the active line ──
Word mode: crisp steps on the source's own word times. */
.wline {
display: block;
}
.w {
color: var(--text-secondary);
transition: color 0.12s ease;
}
.w + .w {
margin-left: 0.32em;
}
.w.sung {
color: #fff;
}
.autochip {
flex: none;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
padding: 2.5px 8px;
border-radius: 9px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.autochip:hover {
background: color-mix(in srgb, var(--accent) 26%, transparent);
}
.mi:disabled {
opacity: 0.6;
}
.mi .mi-hint {
margin-left: auto;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding-left: 10px;
}
.mi-note {
font-size: 11px;
color: #e8a33d;
padding: 2px 10px 6px;
max-width: 240px;
}
.mi-rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
/* Fullscreen scale — same skeleton, bigger voice. */
.lyr.big .lines {
max-height: none;
height: 100%;
min-height: 0;
flex: 1;
}
.lyr.big .line {
font-size: clamp(22px, 3.1vw, 38px);
font-weight: 800;
line-height: 1.35;
padding: 9px 12px;
border-radius: 12px;
letter-spacing: -0.01em;
}
.lyr.big .line.gap {
font-size: 16px;
}
.lyr.big .toolbar {
padding-bottom: 14px;
}
.syncnow {
display: inline-flex;
align-items: center;
gap: 7px;
align-self: flex-start;
margin: 0 2px 10px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
}
.syncnow:hover {
background: var(--accent-hover);
}
/* ── plain text ──
Everything about the box, the type and the fade comes from .lines and
.line above; only the differences live here. Nothing is seekable, so
the rows stay quiet under the pointer, and with no line ever lit there
is no muted/active contrast to carry — the whole sheet reads at the
secondary tone the synced list gives a line once it has been sung.
The tail mirrors the lead rather than reserving a seat (SEAT's
complement) that nothing will ever scroll up to. */
.lines.plain .line {
color: var(--text-secondary);
cursor: default;
}
.lines.plain .line:hover {
background: none;
color: var(--text-secondary);
}
.lines.plain .tail-space {
height: 7%;
}
/* ── states ── */
.statebox {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 34px 12px 22px;
gap: 4px;
}
.state-glyph {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--bg-highlight);
color: var(--text-muted);
margin-bottom: 8px;
}
.state-t {
font-size: 14.5px;
font-weight: 700;
}
.state-s {
font-size: 12px;
color: var(--text-muted);
max-width: 260px;
}
.state-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pbtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 700;
margin-top: 4px;
}
.pbtn:hover:not(:disabled) {
background: var(--accent-hover);
}
.pbtn:disabled {
opacity: 0.5;
}
.pbtn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.pbtn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
/* ── candidate picker ── */
.pick-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
font-weight: 700;
color: var(--text-secondary);
padding: 2px 2px 8px;
}
.note {
font-size: 12px;
color: var(--text-muted);
padding: 4px 2px 8px;
}
.mono {
font-family: Consolas, monospace;
font-size: 11px;
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 4px;
color: var(--text-primary);
}
.cands {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 42vh;
overflow-y: auto;
scrollbar-width: none;
}
.cands::-webkit-scrollbar {
width: 0;
display: none;
}
.cand {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
border-radius: 7px;
text-align: left;
}
.cand:hover {
background: var(--bg-highlight);
}
.c-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.c-t {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-a {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-tag {
flex: none;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 7px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.c-tag.inst {
background: var(--bg-highlight);
color: var(--text-muted);
}
.c-d {
flex: none;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ── paste ── */
.paste textarea {
width: 100%;
min-height: 180px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.6;
outline: none;
}
.paste textarea:focus {
border-color: var(--accent);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@media (prefers-reduced-motion: reduce) {
.shimmer span {
animation: none;
}
.line {
transition: none;
}
}
</style>