forked from Keelrleek/SoulStream
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout.
973 lines
32 KiB
Svelte
973 lines
32 KiB
Svelte
<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, goToView } from "../lib/state.svelte.js";
|
||
import {
|
||
lyricsStore,
|
||
lyricIdentity,
|
||
ensureLyrics,
|
||
noteTrackChange,
|
||
activeLineIndex,
|
||
wordClockMs,
|
||
wordfill,
|
||
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 && 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="fw">{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="Open the lyrics page"
|
||
onclick={() =>
|
||
goToView("lyrics", {
|
||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||
})}
|
||
>
|
||
<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 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;
|
||
}
|
||
.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;
|
||
}
|
||
.fw + .fw {
|
||
margin-left: 0.32em;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
/* ── dark glass ────────────────────────────────────────────────────────
|
||
Ladder in global.css. This pane rides inside Now Playing, so its ground
|
||
is the same 0.7075 frost every other pane surface stands on — with one
|
||
wrinkle worth stating: while the album wash is still painting at the top
|
||
of the panel these surfaces sit on artwork instead, and a white veil
|
||
reads correctly on both. Solid mode is untouched. */
|
||
:global(:root.glass) .line:hover,
|
||
:global(:root.glass) .cand:hover {
|
||
background: var(--glass-hover);
|
||
}
|
||
:global(:root.glass) .tool:hover,
|
||
:global(:root.glass) .pbtn.ghost:hover {
|
||
background: var(--glass-press);
|
||
}
|
||
:global(:root.glass) .nudge,
|
||
:global(:root.glass) .state-glyph,
|
||
:global(:root.glass) .pbtn.ghost,
|
||
:global(:root.glass) .mono,
|
||
:global(:root.glass) .c-tag.inst {
|
||
background: var(--glass-surface);
|
||
}
|
||
:global(:root.glass) .paste textarea {
|
||
background: var(--glass-well);
|
||
border-color: var(--glass-hairline);
|
||
}
|
||
/* The loading shimmer: same two-tone sweep, in white. */
|
||
:global(:root.glass) .shimmer span {
|
||
background: linear-gradient(
|
||
100deg,
|
||
rgba(255, 255, 255, 0.06) 35%,
|
||
rgba(255, 255, 255, 0.13) 50%,
|
||
rgba(255, 255, 255, 0.06) 65%
|
||
);
|
||
background-size: 240% 100%;
|
||
}
|
||
/* Menus stay solid on purpose — see Playlists.svelte. */
|
||
</style>
|