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

+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>