Music — Track + Synced Lyrics View
A dark, karaoke-style track page where simulated playback drives time-synced lyrics. The header pairs a large CSS-drawn album cover and animated equalizer with the title, artist and a waveform scrubber that supports click, drag and keyboard seeking. The main panel auto-scrolls a vertical lyric list so the current line enlarges and gradient-highlights at centre while past lines dim and upcoming lines stay muted. Clicking any line seeks to its timestamp, and font-size and timestamp toggles round out the controls.
MCP
コード
:root {
--bg: #0b0b0f;
--bg-2: #13131a;
--surface: #1a1a22;
--surface-2: #22222c;
--text: #f4f4f7;
--muted: #a0a0ad;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--accent: #1db954;
--accent-2: #8b5cf6;
--accent-3: #ff3d71;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-full: 999px;
/* Cover-driven theme — pulled into header glow + lyric highlight */
--cover-a: #ff3d71;
--cover-b: #8b5cf6;
--lyric-size: 1.35rem;
--shadow-soft: 0 18px 50px rgba(0, 0, 0, 0.45);
--font-display: "Sora", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(900px 520px at 18% -8%, color-mix(in srgb, var(--cover-a) 26%, transparent), transparent 60%),
radial-gradient(820px 480px at 88% 4%, color-mix(in srgb, var(--cover-b) 22%, transparent), transparent 62%),
var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
display: flex;
justify-content: center;
padding: 28px 18px 48px;
transition: background 600ms ease;
}
.stage {
width: 100%;
max-width: 760px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ───────── Player header ───────── */
.player {
display: grid;
grid-template-columns: 132px 1fr;
gap: 22px;
padding: 20px;
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-soft);
position: relative;
overflow: hidden;
}
.player::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(420px 200px at 0% 0%, color-mix(in srgb, var(--cover-a) 22%, transparent), transparent 70%);
pointer-events: none;
}
/* Album cover (CSS-drawn) */
.cover {
position: relative;
width: 132px;
height: 132px;
border-radius: var(--r-md);
overflow: hidden;
background: linear-gradient(145deg, var(--cover-a), var(--cover-b));
box-shadow: 0 14px 34px color-mix(in srgb, var(--cover-a) 40%, transparent);
isolation: isolate;
}
.cover__shape {
position: absolute;
border-radius: var(--r-full);
filter: blur(2px);
mix-blend-mode: screen;
}
.cover__shape.s1 {
width: 96px;
height: 96px;
left: -22px;
top: -18px;
background: radial-gradient(circle at 35% 35%, #fff8, transparent 60%), var(--cover-b);
opacity: 0.85;
}
.cover__shape.s2 {
width: 70px;
height: 70px;
right: -14px;
bottom: -10px;
background: radial-gradient(circle at 60% 40%, #fff6, transparent 62%), var(--cover-a);
opacity: 0.9;
}
.cover__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(transparent 95%, rgba(255, 255, 255, 0.14) 95%),
linear-gradient(90deg, transparent 95%, rgba(255, 255, 255, 0.14) 95%);
background-size: 16px 16px;
mask-image: linear-gradient(180deg, transparent, #000 60%);
opacity: 0.5;
}
.cover__noise {
position: absolute;
inset: 0;
background: radial-gradient(circle at 70% 22%, rgba(255, 255, 255, 0.18), transparent 40%);
}
.cover__eq {
position: absolute;
left: 10px;
bottom: 10px;
display: flex;
align-items: flex-end;
gap: 3px;
height: 26px;
padding: 4px 6px;
border-radius: var(--r-sm);
background: rgba(0, 0, 0, 0.34);
backdrop-filter: blur(4px);
opacity: 0.35;
transition: opacity 220ms ease;
}
.cover__eq.is-on {
opacity: 1;
}
.cover__eq i {
width: 3px;
height: 6px;
border-radius: 2px;
background: #fff;
animation: eq 900ms ease-in-out infinite;
}
.cover__eq.is-on i {
animation-play-state: running;
}
.cover__eq i {
animation-play-state: paused;
}
.cover__eq i:nth-child(1) { animation-delay: -200ms; }
.cover__eq i:nth-child(2) { animation-delay: -520ms; }
.cover__eq i:nth-child(3) { animation-delay: -80ms; }
.cover__eq i:nth-child(4) { animation-delay: -360ms; }
.cover__eq i:nth-child(5) { animation-delay: -620ms; }
@keyframes eq {
0%, 100% { height: 6px; }
50% { height: 22px; }
}
/* Meta */
.meta {
min-width: 0;
position: relative;
}
.meta__kicker {
margin: 2px 0 6px;
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 600;
color: color-mix(in srgb, var(--cover-a) 70%, var(--muted));
}
.meta__title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.5rem, 4.4vw, 2rem);
line-height: 1.06;
margin: 0 0 6px;
letter-spacing: -0.01em;
}
.meta__artist {
margin: 0 0 16px;
color: var(--muted);
font-weight: 500;
font-size: 0.92rem;
}
.meta__album { color: color-mix(in srgb, var(--text) 70%, var(--muted)); }
.dot { opacity: 0.5; padding: 0 2px; }
/* Waveform scrubber */
.scrub {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.time {
font-variant-numeric: tabular-nums;
font-size: 0.78rem;
color: var(--muted);
min-width: 34px;
}
.time--dur { text-align: right; }
.wave {
position: relative;
flex: 1;
height: 34px;
cursor: pointer;
outline: none;
touch-action: none;
}
.wave:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cover-a) 70%, transparent);
border-radius: var(--r-sm);
}
.wave__bars {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 2px;
}
.wave__bars i {
flex: 1;
border-radius: var(--r-full);
background: var(--line-2);
transition: background 120ms ease;
}
.wave__fill {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
pointer-events: none;
}
.wave__fill > .wave__bars i {
background: linear-gradient(180deg, var(--cover-a), var(--cover-b));
}
.wave__head {
position: absolute;
top: 50%;
left: 0;
width: 11px;
height: 11px;
border-radius: var(--r-full);
background: #fff;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--cover-a) 40%, transparent);
pointer-events: none;
}
/* Controls */
.controls {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.btn--play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 20px 10px 16px;
border: none;
border-radius: var(--r-full);
cursor: pointer;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.92rem;
color: #07120c;
background: linear-gradient(180deg, color-mix(in srgb, var(--cover-a) 88%, #fff 12%), var(--cover-a));
box-shadow: 0 10px 26px color-mix(in srgb, var(--cover-a) 45%, transparent);
transition: transform 130ms ease, box-shadow 130ms ease, filter 130ms ease;
}
.btn--play:hover { transform: translateY(-1px); filter: brightness(1.05); }
.btn--play:active { transform: translateY(0); }
.ico {
width: 18px;
height: 18px;
fill: currentColor;
}
.ico--pause { display: none; }
.btn--play[aria-pressed="true"] .ico--play { display: none; }
.btn--play[aria-pressed="true"] .ico--pause { display: block; }
.toggles {
display: flex;
gap: 8px;
margin-left: auto;
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
border-radius: var(--r-full);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
font-family: var(--font-body);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.chip svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.chip:hover { color: var(--text); border-color: var(--line-2); }
.chip[aria-pressed="true"] {
color: var(--text);
border-color: color-mix(in srgb, var(--cover-a) 60%, var(--line-2));
background: color-mix(in srgb, var(--cover-a) 18%, var(--surface-2));
}
/* ───────── Lyrics ───────── */
.lyrics {
position: relative;
height: clamp(360px, 56vh, 540px);
overflow-y: auto;
scroll-behavior: smooth;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: linear-gradient(180deg, var(--bg-2), var(--surface));
outline: none;
-webkit-overflow-scrolling: touch;
}
.lyrics:focus-visible {
border-color: color-mix(in srgb, var(--cover-a) 55%, var(--line-2));
}
.lyrics__list {
list-style: none;
margin: 0;
/* big top/bottom padding lets first & last line reach the centre */
padding: 46% 28px;
display: flex;
flex-direction: column;
gap: 4px;
}
.line {
margin: 0;
padding: 11px 14px;
border: none;
width: 100%;
text-align: left;
background: none;
cursor: pointer;
border-radius: var(--r-md);
color: color-mix(in srgb, var(--muted) 78%, transparent);
font-family: var(--font-display);
font-weight: 600;
font-size: calc(var(--lyric-size) * 0.84);
letter-spacing: -0.01em;
line-height: 1.32;
transition: color 280ms ease, transform 280ms ease, opacity 280ms ease, background 200ms ease;
position: relative;
display: flex;
align-items: baseline;
gap: 12px;
}
.line:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.line:focus-visible {
outline: 2px solid color-mix(in srgb, var(--cover-a) 70%, transparent);
outline-offset: 1px;
}
.line__ts {
display: none;
flex: 0 0 auto;
font-family: var(--font-body);
font-variant-numeric: tabular-nums;
font-size: 0.7rem;
font-weight: 600;
color: var(--muted);
opacity: 0.65;
padding-top: 0.35em;
min-width: 34px;
}
.stage.show-ts .line__ts { display: inline-block; }
.line__text { flex: 1; }
/* states driven by JS */
.line.is-past {
color: color-mix(in srgb, var(--muted) 50%, transparent);
opacity: 0.55;
}
.line.is-active {
color: var(--text);
font-size: var(--lyric-size);
transform: scale(1.0);
font-weight: 800;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--cover-a) 16%, transparent),
transparent 70%
);
}
.line.is-active .line__text {
background: linear-gradient(90deg, var(--cover-a), var(--cover-b));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.line.is-active::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 56%;
border-radius: var(--r-full);
background: linear-gradient(180deg, var(--cover-a), var(--cover-b));
box-shadow: 0 0 16px color-mix(in srgb, var(--cover-a) 60%, transparent);
}
.line.is-instrumental .line__text {
letter-spacing: 0.5em;
opacity: 0.8;
}
/* large size mode */
.stage.size-lg { --lyric-size: 1.9rem; }
/* edge fades */
.lyrics__fade {
position: sticky;
left: 0;
width: 100%;
height: 64px;
pointer-events: none;
z-index: 2;
}
.lyrics__fade--top {
top: 0;
margin-bottom: -64px;
background: linear-gradient(180deg, var(--bg-2), transparent);
}
.lyrics__fade--bottom {
bottom: 0;
margin-top: -64px;
background: linear-gradient(0deg, var(--surface), transparent);
}
/* ───────── Footnote ───────── */
.footnote {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 6px;
font-size: 0.78rem;
color: var(--muted);
}
.footnote__live {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: color-mix(in srgb, var(--text) 80%, var(--muted));
}
.footnote__live::before {
content: "";
width: 7px;
height: 7px;
border-radius: var(--r-full);
background: var(--cover-a);
box-shadow: 0 0 10px var(--cover-a);
}
/* ───────── Toast ───────── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 18px);
padding: 11px 18px;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-radius: var(--r-full);
font-size: 0.84rem;
font-weight: 600;
color: var(--text);
box-shadow: var(--shadow-soft);
opacity: 0;
pointer-events: none;
transition: opacity 240ms ease, transform 240ms ease;
z-index: 40;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
.cover__eq i { animation: none; }
}
/* ───────── Responsive ───────── */
@media (max-width: 520px) {
body { padding: 16px 12px 36px; }
.player {
grid-template-columns: 1fr;
gap: 16px;
padding: 16px;
}
.cover { width: 96px; height: 96px; }
.cover__eq { left: 8px; bottom: 8px; }
.meta__title { font-size: 1.55rem; }
.toggles { margin-left: 0; width: 100%; }
.controls { gap: 10px; }
.btn--play { flex: 0 0 auto; }
.lyrics__list { padding: 56% 18px; }
.footnote { font-size: 0.72rem; }
}(() => {
"use strict";
/* ───────── Track data (fictional) ───────── */
const DURATION = 222; // 3:42 in seconds
// Time-synced lyric schedule. `t` = start time (seconds).
// Lines are sorted; the active line is the last one whose t <= currentTime.
const LYRICS = [
{ t: 0, text: "♪ ♪ ♪", kind: "instrumental" },
{ t: 8, text: "Paper lanterns on a borrowed string" },
{ t: 13, text: "We lit them slow so the dark could sing" },
{ t: 18, text: "Half a city sleeping under neon rain" },
{ t: 23, text: "And every window held a different name" },
{ t: 29, text: "♪ ♪", kind: "instrumental" },
{ t: 34, text: "So tell me where the river goes" },
{ t: 39, text: "When the midnight reservoir overflows" },
{ t: 45, text: "I was counting all the lights I'd lose" },
{ t: 50, text: "You were humming something I never knew" },
{ t: 57, text: "(And the tide came in, the tide came in)" },
{ t: 63, text: "Paper lanterns, let them go" },
{ t: 68, text: "Higher than the radio glow" },
{ t: 74, text: "We don't need a map for the morning side" },
{ t: 80, text: "Just the velvet static and the open sky" },
{ t: 87, text: "♪ ♪ ♪", kind: "instrumental" },
{ t: 95, text: "Velvet static on the kitchen floor" },
{ t: 100, text: "You said forever, then you said one more" },
{ t: 106, text: "Every chorus is a folded note" },
{ t: 111, text: "A paper boat for the words I wrote" },
{ t: 118, text: "So tell me where the river goes" },
{ t: 123, text: "When the midnight reservoir overflows" },
{ t: 129, text: "I was counting all the lights I'd lose" },
{ t: 134, text: "You were humming something I never knew" },
{ t: 141, text: "(And the tide came in, the tide came in)" },
{ t: 147, text: "Paper lanterns, let them go" },
{ t: 152, text: "Higher than the radio glow" },
{ t: 158, text: "We don't need a map for the morning side" },
{ t: 164, text: "Just the velvet static and the open sky" },
{ t: 171, text: "♪ ♪", kind: "instrumental" },
{ t: 178, text: "Let them go, let them go" },
{ t: 183, text: "Over the reservoir, soft and slow" },
{ t: 189, text: "Let them go, let them go" },
{ t: 194, text: "Till the only light I know is you" },
{ t: 201, text: "Paper lanterns on a borrowed string" },
{ t: 207, text: "We lit them slow so the dark could sing" },
{ t: 214, text: "♪ ♪ ♪", kind: "instrumental" },
];
/* ───────── Element refs ───────── */
const $ = (id) => document.getElementById(id);
const stage = $("stage");
const playBtn = $("playBtn");
const playLabel = $("playLabel");
const coverEq = $("coverEq");
const wave = $("wave");
const waveBars = $("waveBars");
const waveFill = $("waveFill");
const waveHead = $("waveHead");
const timeCur = $("timeCur");
const timeDur = $("timeDur");
const sizeBtn = $("sizeBtn");
const sizeLabel = $("sizeLabel");
const tsBtn = $("tsBtn");
const lyricsBox = $("lyrics");
const lyricList = $("lyricList");
const liveLine = $("liveLine");
const toastEl = $("toast");
/* ───────── Helpers ───────── */
const fmt = (s) => {
s = Math.max(0, Math.round(s));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
let toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("is-show"), 1800);
}
/* ───────── Build waveform bars ───────── */
const BAR_COUNT = 72;
const heights = [];
for (let i = 0; i < BAR_COUNT; i++) {
// pseudo-musical envelope: layered sines + a little jitter
const env =
0.45 +
0.4 * Math.abs(Math.sin(i * 0.42)) +
0.25 * Math.abs(Math.sin(i * 0.13 + 1.7));
const jitter = ((i * 9301 + 49297) % 233) / 233;
heights.push(Math.min(1, env * (0.7 + jitter * 0.5)));
}
function paintBars(target) {
const frag = document.createDocumentFragment();
heights.forEach((h) => {
const b = document.createElement("i");
b.style.height = `${Math.round(20 + h * 80)}%`;
frag.appendChild(b);
});
target.appendChild(frag);
}
paintBars(waveBars);
// mirror set inside the fill layer (clipped by width)
const fillBars = document.createElement("div");
fillBars.className = "wave__bars";
waveFill.appendChild(fillBars);
paintBars(fillBars);
/* ───────── Build lyric lines ───────── */
const lineEls = LYRICS.map((ly, i) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "line";
if (ly.kind === "instrumental") btn.classList.add("is-instrumental");
btn.dataset.index = String(i);
const ts = document.createElement("span");
ts.className = "line__ts";
ts.textContent = fmt(ly.t);
const txt = document.createElement("span");
txt.className = "line__text";
txt.textContent = ly.text;
btn.append(ts, txt);
btn.addEventListener("click", () => seekToLine(i));
li.appendChild(btn);
lyricList.appendChild(li);
return btn;
});
/* ───────── Playback state ───────── */
let current = 0; // seconds
let playing = false;
let rafId = null;
let lastTs = 0;
let activeIdx = -1;
timeDur.textContent = fmt(DURATION);
function activeIndexFor(time) {
let idx = 0;
for (let i = 0; i < LYRICS.length; i++) {
if (LYRICS[i].t <= time) idx = i;
else break;
}
return idx;
}
function renderActive(force) {
const idx = activeIndexFor(current);
if (idx === activeIdx && !force) return;
activeIdx = idx;
lineEls.forEach((el, i) => {
el.classList.toggle("is-active", i === idx);
el.classList.toggle("is-past", i < idx);
});
const el = lineEls[idx];
if (el) {
// auto-scroll active line to centre of the lyric viewport
const boxH = lyricsBox.clientHeight;
const target = el.offsetTop - boxH / 2 + el.offsetHeight / 2;
lyricsBox.scrollTo({ top: target, behavior: "smooth" });
liveLine.textContent = LYRICS[idx].kind === "instrumental"
? "Instrumental"
: `“${LYRICS[idx].text.slice(0, 38)}${LYRICS[idx].text.length > 38 ? "…" : ""}”`;
}
}
function renderScrub() {
const pct = (current / DURATION) * 100;
waveFill.style.width = `${pct}%`;
waveHead.style.left = `${pct}%`;
timeCur.textContent = fmt(current);
wave.setAttribute("aria-valuenow", String(Math.round(pct)));
wave.setAttribute("aria-valuetext", `${fmt(current)} of ${fmt(DURATION)}`);
}
function tick(ts) {
if (!playing) return;
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
current += dt;
if (current >= DURATION) {
current = DURATION;
renderScrub();
renderActive(true);
stop(true);
toast("Track finished");
return;
}
renderScrub();
renderActive(false);
rafId = requestAnimationFrame(tick);
}
function play() {
if (playing) return;
if (current >= DURATION) current = 0;
playing = true;
lastTs = 0;
playBtn.setAttribute("aria-pressed", "true");
playBtn.setAttribute("aria-label", "Pause");
playLabel.textContent = "Pause";
coverEq.classList.add("is-on");
rafId = requestAnimationFrame(tick);
}
function stop(ended) {
playing = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
lastTs = 0;
playBtn.setAttribute("aria-pressed", "false");
playBtn.setAttribute("aria-label", "Play");
playLabel.textContent = ended ? "Replay" : "Play";
coverEq.classList.remove("is-on");
}
function toggle() {
playing ? stop(false) : play();
}
function seekToTime(time, announce) {
current = Math.max(0, Math.min(DURATION, time));
renderScrub();
renderActive(true);
if (announce) toast(`Seek · ${fmt(current)}`);
}
function seekToLine(i) {
seekToTime(LYRICS[i].t, false);
liveLine.textContent = "Jumped to line";
if (!playing) play();
toast(`Line ${i + 1} · ${fmt(LYRICS[i].t)}`);
}
/* ───────── Scrubber interaction ───────── */
function timeFromPointer(clientX) {
const r = wave.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
return ratio * DURATION;
}
let dragging = false;
wave.addEventListener("pointerdown", (e) => {
dragging = true;
wave.setPointerCapture(e.pointerId);
seekToTime(timeFromPointer(e.clientX), false);
});
wave.addEventListener("pointermove", (e) => {
if (dragging) seekToTime(timeFromPointer(e.clientX), false);
});
wave.addEventListener("pointerup", (e) => {
if (!dragging) return;
dragging = false;
try { wave.releasePointerCapture(e.pointerId); } catch (_) {}
toast(`Seek · ${fmt(current)}`);
});
wave.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 15 : 5;
if (e.key === "ArrowRight") { seekToTime(current + step, true); e.preventDefault(); }
else if (e.key === "ArrowLeft") { seekToTime(current - step, true); e.preventDefault(); }
else if (e.key === "Home") { seekToTime(0, true); e.preventDefault(); }
else if (e.key === "End") { seekToTime(DURATION, true); e.preventDefault(); }
else if (e.key === " " || e.key === "Enter") { toggle(); e.preventDefault(); }
});
/* ───────── Controls ───────── */
playBtn.addEventListener("click", toggle);
sizeBtn.addEventListener("click", () => {
const big = stage.classList.toggle("size-lg");
sizeBtn.setAttribute("aria-pressed", String(big));
sizeLabel.textContent = big ? "AA" : "Aa";
toast(big ? "Large lyrics" : "Default lyrics");
// keep active line centred after resize
requestAnimationFrame(() => renderActive(true));
});
tsBtn.addEventListener("click", () => {
const on = stage.classList.toggle("show-ts");
tsBtn.setAttribute("aria-pressed", String(on));
toast(on ? "Timestamps shown" : "Timestamps hidden");
});
// global space toggles play unless focus is on the scrubber (it handles its own)
document.addEventListener("keydown", (e) => {
if (e.key === " " && e.target !== wave && !/^(BUTTON)$/.test(e.target.tagName)) {
toggle();
e.preventDefault();
}
});
/* ───────── Init ───────── */
renderScrub();
renderActive(true);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music — Track + Synced Lyrics View</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage" id="stage">
<!-- Now-playing header -->
<header class="player" aria-label="Now playing">
<div class="cover" aria-hidden="true">
<span class="cover__shape s1"></span>
<span class="cover__shape s2"></span>
<span class="cover__grid"></span>
<span class="cover__noise"></span>
<div class="cover__eq" id="coverEq">
<i></i><i></i><i></i><i></i><i></i>
</div>
</div>
<div class="meta">
<p class="meta__kicker">Now Playing · Synced Lyrics</p>
<h1 class="meta__title">Paper Lanterns</h1>
<p class="meta__artist">Neon Tides <span class="dot">·</span> <span class="meta__album">Midnight Reservoir</span></p>
<div class="scrub">
<span class="time time--cur" id="timeCur">0:00</span>
<div
class="wave"
id="wave"
role="slider"
tabindex="0"
aria-label="Seek through track"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-valuetext="0:00 of 3:42"
>
<div class="wave__bars" id="waveBars"></div>
<div class="wave__fill" id="waveFill"></div>
<div class="wave__head" id="waveHead"></div>
</div>
<span class="time time--dur" id="timeDur">3:42</span>
</div>
<div class="controls">
<button class="btn btn--play" id="playBtn" aria-pressed="false" aria-label="Play">
<svg class="ico ico--play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg class="ico ico--pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
<span class="btn__label" id="playLabel">Play</span>
</button>
<div class="toggles">
<button class="chip" id="sizeBtn" aria-pressed="false" aria-label="Toggle lyric size">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7V4h18v3M9 20h6M12 4v16"/></svg>
<span id="sizeLabel">Aa</span>
</button>
<button class="chip" id="tsBtn" aria-pressed="false" aria-label="Toggle timestamps">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<span>Timestamps</span>
</button>
</div>
</div>
</div>
</header>
<!-- Lyrics -->
<section class="lyrics" id="lyrics" aria-label="Time-synced lyrics" tabindex="0">
<ol class="lyrics__list" id="lyricList"></ol>
<div class="lyrics__fade lyrics__fade--top" aria-hidden="true"></div>
<div class="lyrics__fade lyrics__fade--bottom" aria-hidden="true"></div>
</section>
<footer class="footnote">
<span class="footnote__live" id="liveLine">Tap any line to seek</span>
<span class="footnote__credit">Lyrics by Neon Tides · Illustrative demo</span>
</footer>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Track + Synced Lyrics View
A focused player page for the fictional track Paper Lanterns by Neon Tides, from the album Midnight Reservoir. The header pairs a fully CSS-drawn cover — layered gradients, blurred shapes, a fading grid and a tiny equalizer that animates only while playing — with the track title, artist line and a waveform scrubber. The cover’s two accent colors theme the whole page: they tint the progress fill, the play button glow and the highlighted lyric line.
The main panel is a vertical, auto-scrolling lyric list. A timed schedule drives the karaoke feel: the current line enlarges, picks up a gradient text fill and an accent bar, and smoothly scrolls to the centre of the viewport as playback advances. Past lines dim away, upcoming lines stay muted, and instrumental breaks render as spaced glyphs. Clicking any line seeks playback to that line’s timestamp and starts it if paused.
Playback is fully simulated with requestAnimationFrame — no audio files. The waveform scrubber exposes role="slider" with live aria-valuenow/aria-valuetext and supports click-to-seek, pointer dragging and ArrowLeft/ArrowRight (Shift for larger steps) keyboard seeking. Play and the two toggles use aria-pressed: one switches lyric font size, the other reveals per-line timestamps. A small toast() helper confirms seeks and mode changes, and the layout collapses cleanly to a single column down to ~360px.
Illustrative UI only — fictional artists, albums, tracks, and data. No real audio playback.