feat: smooth karaoke scroll — no blink on lyric advance

Replace blink-all transition with a circular ring buffer: each lyric
line is absolutely positioned (30px slot). On advance the ring scrolls
up, the outgoing element is recycled to the bottom with new content
(transition disabled for the snap). All style properties (top, opacity,
font-size, color, text-shadow) animate together via CSS transition so
the new active line gains focus as it scrolls into center position.
Jumps (seek / track change) still use a crossfade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-06-08 17:05:40 -03:00
parent 10b3bbbf2e
commit fdc68594d5
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D

View file

@ -237,65 +237,35 @@ html, body {
}
/* ============================================================
LYRICS DISPLAY — 5-line karaoke style
LYRICS DISPLAY — 5-line karaoke scroll
============================================================ */
.lyric-display {
position: absolute;
left: 2%;
top: 4%;
max-width: 38%;
height: 150px; /* 5 slots × 30px */
overflow: hidden;
pointer-events: none;
opacity: 0;
transition: opacity 0.5s ease;
}
.lyric-ctx {
position: absolute;
width: 100%;
height: 30px;
line-height: 30px;
font-family: 'Courier New', monospace;
line-height: 1.5;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.45s ease;
}
/* slot 1: far past */
.lyric-ctx:nth-child(1) {
color: #888;
font-size: clamp(11px, 0.85vw, 13px);
opacity: 0.22;
margin-bottom: 1px;
}
/* slot 2: near past */
.lyric-ctx:nth-child(2) {
color: #bbb;
font-size: clamp(13px, 1.0vw, 15px);
opacity: 0.48;
margin-bottom: 3px;
}
/* slot 3: active */
.lyric-ctx:nth-child(3) {
color: #fff;
font-size: clamp(16px, 1.3vw, 18px);
opacity: 1;
margin-bottom: 3px;
text-shadow:
0 0 18px var(--theme-color, rgba(0,200,255,0.85)),
0 0 6px var(--theme-color, rgba(0,200,255,0.5)),
0 2px 10px rgba(0,0,0,0.95),
-1px -1px 0 rgba(0,0,0,0.7),
1px 1px 0 rgba(0,0,0,0.7);
}
/* slot 4: near future */
.lyric-ctx:nth-child(4) {
color: #bbb;
font-size: clamp(13px, 1.0vw, 15px);
opacity: 0.48;
margin-bottom: 1px;
}
/* slot 5: far future */
.lyric-ctx:nth-child(5) {
color: #888;
font-size: clamp(11px, 0.85vw, 13px);
opacity: 0.22;
transition:
top 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.45s ease,
font-size 0.35s ease,
color 0.35s ease,
text-shadow 0.35s ease;
}
/* ============================================================
@ -1203,14 +1173,74 @@ function applyTheme(rgb) {
});
}
const albumArtEl = document.getElementById('albumArt');
const fogWisps = document.querySelectorAll('.fog-wisp');
const albumArtEl = document.getElementById('albumArt');
const fogWisps = document.querySelectorAll('.fog-wisp');
const lyricDisplay = document.getElementById('lyricDisplay');
const lyricCtxEls = Array.from(document.querySelectorAll('.lyric-ctx'));
let lastArtUrl = '';
let currentLyricKey = '';
let lyricFadeTimer = null;
let lastArtUrl = '';
/* ── karaoke scroll ── */
const SLOT_H = 30; // px per slot — must match CSS height: 150px / 5
const SCROLL_MS = 500; // must match CSS transition: top 0.5s
const ACTIVE_SHADOW =
'0 0 18px var(--theme-color,rgba(0,200,255,0.85)),' +
'0 0 6px var(--theme-color,rgba(0,200,255,0.5)),' +
'0 2px 10px rgba(0,0,0,0.95)';
const SLOT_PROPS = [
{ opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' },
{ opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' },
{ opacity:'1', fontSize:'clamp(16px,1.3vw,18px)', color:'#fff', shadow: ACTIVE_SHADOW },
{ opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' },
{ opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' },
];
let ring = Array.from(document.querySelectorAll('.lyric-ctx'));
let currentActive = '';
let scrollBusy = false;
function applySlotVisuals(el, slot) {
if (slot < 0 || slot > 4) { el.style.opacity = '0'; return; }
const p = SLOT_PROPS[slot];
el.style.opacity = p.opacity;
el.style.fontSize = p.fontSize;
el.style.color = p.color;
el.style.textShadow = p.shadow;
}
function initSlots(context) {
ring.forEach((el, i) => {
el.style.transition = 'none';
el.textContent = context[i]?.text ?? '';
el.style.top = (i * SLOT_H) + 'px';
applySlotVisuals(el, i);
});
requestAnimationFrame(() => requestAnimationFrame(() => {
ring.forEach(el => { el.style.transition = ''; });
}));
}
function scrollUp(context) {
if (scrollBusy) return;
scrollBusy = true;
ring.forEach((el, i) => {
el.style.top = ((i - 1) * SLOT_H) + 'px';
applySlotVisuals(el, i - 1);
});
setTimeout(() => {
const recycled = ring[0];
recycled.style.transition = 'none';
recycled.textContent = context[4]?.text ?? '';
recycled.style.top = (4 * SLOT_H) + 'px';
applySlotVisuals(recycled, 4);
requestAnimationFrame(() => requestAnimationFrame(() => {
recycled.style.transition = '';
}));
ring = [...ring.slice(1), ring[0]];
scrollBusy = false;
}, SCROLL_MS + 50);
}
function updateLyricContext(context) {
if (!context || !context.length || !isPlaying) {
@ -1222,30 +1252,22 @@ function updateLyricContext(context) {
lyricDisplay.style.opacity = '0';
return;
}
const newKey = context.map(c => c.text).join('\x00');
if (newKey === currentLyricKey) {
if (active.text === currentActive) {
lyricDisplay.style.opacity = '1';
return;
}
currentLyricKey = newKey;
const wasNextLine = ring[3]?.textContent === active.text;
currentActive = active.text;
const wasVisible = lyricDisplay.style.opacity === '1';
if (wasVisible) {
if (lyricFadeTimer) clearTimeout(lyricFadeTimer);
lyricCtxEls.forEach(el => {
el.style.transition = 'opacity 0.18s ease';
el.style.opacity = '0';
});
lyricFadeTimer = setTimeout(() => {
lyricFadeTimer = null;
context.forEach((item, i) => { lyricCtxEls[i].textContent = item.text; });
lyricCtxEls.forEach(el => { el.style.transition = ''; el.style.opacity = ''; });
lyricDisplay.style.opacity = '1';
}, 200);
} else {
context.forEach((item, i) => { lyricCtxEls[i].textContent = item.text; });
lyricCtxEls.forEach(el => { el.style.opacity = ''; });
if (lyricDisplay.style.opacity !== '1') {
initSlots(context);
lyricDisplay.style.opacity = '1';
} else if (wasNextLine) {
scrollUp(context);
} else {
// jumped position: crossfade to new context
lyricDisplay.style.opacity = '0';
setTimeout(() => { initSlots(context); lyricDisplay.style.opacity = '1'; }, 400);
}
}