From 4c4b1bbb96b1744b87bfb534445c1841ac13d60d Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:33:12 -0300 Subject: [PATCH] feat: verse-based lyric display with smooth active-line transitions Replace circular ring-buffer scroll with verse-aware display: backend now groups lyrics into verses on empty lines or timestamp gaps >3.5s and exposes verse_lines + verse_active; frontend rebuilds the DOM once per verse (fade-out/in) and animates only opacity/font/glow when the active line advances within the same verse. CSS mask-image gradient replaces hard overflow clipping at top and bottom edges. Co-Authored-By: Claude Sonnet 4.6 --- plugin/contents/ui/dancer.html | 141 ++++++++++++++++----------------- 1 file changed, 69 insertions(+), 72 deletions(-) diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 7345ffb..0602200 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -237,18 +237,20 @@ html, body { } /* ============================================================ - LYRICS DISPLAY — 5-line karaoke scroll + LYRICS DISPLAY — verse karaoke ============================================================ */ .lyric-display { position: absolute; left: 2%; top: 4%; - width: 38%; /* explicit width needed for abs-pos children */ - height: 150px; /* 5 slots × 30px */ + width: 38%; + height: 150px; /* 5 visible slots × 30px */ overflow: hidden; pointer-events: none; opacity: 0; transition: opacity 0.5s ease; + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%); } .lyric-ctx { position: absolute; @@ -894,14 +896,8 @@ html, body { - -
-
-
-
-
-
-
+ +
@@ -1180,91 +1176,91 @@ const lyricDisplay = document.getElementById('lyricDisplay'); 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 +/* ── verse display ── */ +const SLOT_H = 30; +const CENTER_PX = 60; // top of the active slot within the 150px container 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:'' }, +const VERSE_STYLES = [ + { opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' }, // dist -2 + { opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' }, // dist -1 + { opacity:'1', fontSize:'clamp(16px,1.3vw,18px)', color:'#fff', shadow: ACTIVE_SHADOW }, // dist 0 + { opacity:'0.48', fontSize:'clamp(13px,1.0vw,15px)', color:'#bbb', shadow:'' }, // dist +1 + { opacity:'0.2', fontSize:'clamp(11px,0.85vw,13px)', color:'#777', shadow:'' }, // dist +2 ]; -let ring = Array.from(document.querySelectorAll('.lyric-ctx')); -let currentActive = ''; -let scrollBusy = false; +let verseEls = []; +let currentVerseKey = ''; +let currentVerseActive = -1; -function applySlotVisuals(el, slot) { - if (slot < 0 || slot > 4) { el.style.opacity = '0'; return; } - const p = SLOT_PROPS[slot]; +function applyVerseStyle(el, dist) { + if (dist < -2 || dist > 2) { + el.style.opacity = '0'; el.style.fontSize = 'clamp(11px,0.85vw,13px)'; + el.style.color = '#777'; el.style.textShadow = ''; return; + } + const p = VERSE_STYLES[dist + 2]; 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); +function positionVerseEls(verse_active) { + verseEls.forEach((el, i) => { + const dist = i - verse_active; + el.style.top = (CENTER_PX + dist * SLOT_H) + 'px'; + applyVerseStyle(el, dist); }); +} + +function buildVerse(verse_lines, verse_active) { + lyricDisplay.innerHTML = ''; + verseEls = verse_lines.map(text => { + const el = document.createElement('div'); + el.className = 'lyric-ctx'; + el.textContent = text; + el.style.transition = 'none'; + lyricDisplay.appendChild(el); + return el; + }); + positionVerseEls(verse_active); requestAnimationFrame(() => requestAnimationFrame(() => { - ring.forEach(el => { el.style.transition = ''; }); + verseEls.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) { +function updateLyricVerse(verse_lines, verse_active) { + if (!verse_lines || !verse_lines.length || verse_active < 0 || !isPlaying) { lyricDisplay.style.opacity = '0'; + currentVerseKey = ''; + currentVerseActive = -1; return; } - const active = context.find(c => c.offset === 0); - if (!active || !active.text) { - lyricDisplay.style.opacity = '0'; - return; - } - if (active.text === currentActive) { - lyricDisplay.style.opacity = '1'; - return; - } - currentActive = active.text; + const verseKey = verse_lines.join('\x00'); - if (lyricDisplay.style.opacity !== '1') { - initSlots(context); + if (verseKey !== currentVerseKey) { + currentVerseKey = verseKey; + currentVerseActive = verse_active; + if (lyricDisplay.style.opacity === '1') { + lyricDisplay.style.opacity = '0'; + setTimeout(() => { + buildVerse(verse_lines, verse_active); + lyricDisplay.style.opacity = '1'; + }, 500); + } else { + buildVerse(verse_lines, verse_active); + lyricDisplay.style.opacity = '1'; + } + } else if (verse_active !== currentVerseActive) { + currentVerseActive = verse_active; + positionVerseEls(verse_active); } else { - scrollUp(context); + lyricDisplay.style.opacity = '1'; } - lyricDisplay.style.opacity = '1'; } function fetchCurrent() { @@ -1277,7 +1273,7 @@ function fetchCurrent() { document.getElementById('albumArtImg').src = 'http://127.0.0.1:8765/art?' + Date.now(); } - updateLyricContext(d.lyric_context || []); + updateLyricVerse(d.verse_lines || [], d.verse_active ?? -1); }) .catch(() => {}); } @@ -1340,7 +1336,8 @@ function updateMusic(artist, title) { if (!playing) { vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; }); lyricDisplay.style.opacity = '0'; - currentActive = ''; + currentVerseKey = ''; + currentVerseActive = -1; } if (playing) fetchCurrent(); const key = artist + '|' + title;