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:
parent
10b3bbbf2e
commit
fdc68594d5
1 changed files with 90 additions and 68 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue