From 10b3bbbf2ed1e99b03e0aa3adeb786c90d0a57ba Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 16:53:17 -0300 Subject: [PATCH 1/5] feat: multi-player support + 5-line karaoke lyrics display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get-playing.sh: iterates playerctl players, picks the one actually Playing; QML now calls this instead of bare playerctl metadata so a paused Spotify no longer blocks a playing YouTube from showing - dancer.html: replace single lyric line with 5-slot karaoke display — active line (center) is full-brightness with theme-color glow; 2 lines before/after are progressively dimmed; slots fade out/in (0.18s/0.45s) when the active line advances Co-Authored-By: Claude Sonnet 4.6 --- plugin/contents/ui/dancer.html | 113 +++++++++++++++++++++++++----- plugin/contents/ui/get-playing.sh | 8 +++ plugin/contents/ui/main.qml | 2 +- 3 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 plugin/contents/ui/get-playing.sh diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 75b0ab3..1e69110 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -237,23 +237,46 @@ html, body { } /* ============================================================ - LYRICS DISPLAY + LYRICS DISPLAY — 5-line karaoke style ============================================================ */ .lyric-display { position: absolute; left: 2%; top: 4%; - max-width: 36%; + max-width: 38%; pointer-events: none; opacity: 0; transition: opacity 0.5s ease; } -.lyric-line { - color: #ffffff; +.lyric-ctx { font-family: 'Courier New', monospace; - font-size: clamp(16px, 1.3vw, 18px); line-height: 1.5; - letter-spacing: 0.03em; + 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)), @@ -261,6 +284,19 @@ html, body { -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; +} /* ============================================================ SCANLINES OVERLAY @@ -887,9 +923,13 @@ html, body { - +
-
+
+
+
+
+
@@ -1166,10 +1206,48 @@ function applyTheme(rgb) { const albumArtEl = document.getElementById('albumArt'); const fogWisps = document.querySelectorAll('.fog-wisp'); const lyricDisplay = document.getElementById('lyricDisplay'); -const lyricLineEl = document.getElementById('lyricLine'); +const lyricCtxEls = Array.from(document.querySelectorAll('.lyric-ctx')); -let lastArtUrl = ''; -let lastLyricLine = ''; +let lastArtUrl = ''; +let currentLyricKey = ''; +let lyricFadeTimer = null; + +function updateLyricContext(context) { + if (!context || !context.length || !isPlaying) { + lyricDisplay.style.opacity = '0'; + return; + } + const active = context.find(c => c.offset === 0); + if (!active || !active.text) { + lyricDisplay.style.opacity = '0'; + return; + } + const newKey = context.map(c => c.text).join('\x00'); + if (newKey === currentLyricKey) { + lyricDisplay.style.opacity = '1'; + return; + } + currentLyricKey = newKey; + + 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 = ''; }); + lyricDisplay.style.opacity = '1'; + } +} function fetchCurrent() { fetch('http://127.0.0.1:8765/current') @@ -1181,12 +1259,7 @@ function fetchCurrent() { document.getElementById('albumArtImg').src = 'http://127.0.0.1:8765/art?' + Date.now(); } - const line = d.lyric_line || ''; - if (line !== lastLyricLine) { - lastLyricLine = line; - lyricLineEl.textContent = line; - } - lyricDisplay.style.opacity = (isPlaying && line) ? '1' : '0'; + updateLyricContext(d.lyric_context || []); }) .catch(() => {}); } @@ -1246,7 +1319,11 @@ function updateMusic(artist, title) { document.querySelector('.robot-wrap').style.opacity = playing ? '1' : '0'; document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0'; albumArtEl.style.opacity = playing ? '1' : '0'; - if (!playing) vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; }); + if (!playing) { + vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; }); + lyricDisplay.style.opacity = '0'; + currentLyricKey = ''; + } if (playing) fetchCurrent(); const key = artist + '|' + title; if (el.dataset.key === key) return; diff --git a/plugin/contents/ui/get-playing.sh b/plugin/contents/ui/get-playing.sh new file mode 100644 index 0000000..47a942e --- /dev/null +++ b/plugin/contents/ui/get-playing.sh @@ -0,0 +1,8 @@ +#!/bin/bash +for player in $(playerctl -l 2>/dev/null); do + if [ "$(playerctl --player "$player" status 2>/dev/null)" = "Playing" ]; then + playerctl --player "$player" metadata --format "{{status}}||{{artist}}||{{title}}" 2>/dev/null + exit 0 + fi +done +printf "||" diff --git a/plugin/contents/ui/main.qml b/plugin/contents/ui/main.qml index 173dcae..7402871 100644 --- a/plugin/contents/ui/main.qml +++ b/plugin/contents/ui/main.qml @@ -38,7 +38,7 @@ WallpaperItem { Plasma5Support.DataSource { id: musicSource engine: "executable" - connectedSources: ["playerctl metadata --format '{{status}}||{{artist}}||{{title}}' 2>/dev/null || echo '||'"] + connectedSources: ["bash " + Qt.resolvedUrl("get-playing.sh").toString().replace("file://", "")] interval: 2000 onNewData: function(sourceName, data) { From fdc68594d58c449a35e6369109d7d0b3bf7a5be7 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:05:40 -0300 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20smooth=20karaoke=20scroll=20?= =?UTF-8?q?=E2=80=94=20no=20blink=20on=20lyric=20advance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugin/contents/ui/dancer.html | 158 +++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 68 deletions(-) diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 1e69110..1bcf420 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -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); } } From b75cec0107f14de0013dbbf4295130124fc322dd Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:07:41 -0300 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20correct=20stale=20currentLyricKey=20?= =?UTF-8?q?ref=20=E2=86=92=20currentActive=20in=20updateMusic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- plugin/contents/ui/dancer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 1bcf420..9f66db5 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -1344,7 +1344,7 @@ function updateMusic(artist, title) { if (!playing) { vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; }); lyricDisplay.style.opacity = '0'; - currentLyricKey = ''; + currentActive = ''; } if (playing) fetchCurrent(); const key = artist + '|' + title; From 2b05d5844d8c65eb50586d776dba9b78e3011bf4 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:16:30 -0300 Subject: [PATCH 4/5] fix: explicit width on lyric-display + simplify scroll logic position:absolute children with width:100% require explicit parent width. max-width alone leaves the container at 0px, hiding all text via overflow:hidden. Also removed jump-detection branch to avoid opacity:0 dead-ends. Co-Authored-By: Claude Sonnet 4.6 --- plugin/contents/ui/dancer.html | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 9f66db5..7345ffb 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -243,7 +243,7 @@ html, body { position: absolute; left: 2%; top: 4%; - max-width: 38%; + width: 38%; /* explicit width needed for abs-pos children */ height: 150px; /* 5 slots × 30px */ overflow: hidden; pointer-events: none; @@ -252,6 +252,7 @@ html, body { } .lyric-ctx { position: absolute; + left: 0; width: 100%; height: 30px; line-height: 30px; @@ -261,10 +262,10 @@ html, body { overflow: hidden; text-overflow: ellipsis; 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, + 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; } @@ -1256,19 +1257,14 @@ function updateLyricContext(context) { lyricDisplay.style.opacity = '1'; return; } - const wasNextLine = ring[3]?.textContent === active.text; currentActive = active.text; 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); + scrollUp(context); } + lyricDisplay.style.opacity = '1'; } function fetchCurrent() { From 4c4b1bbb96b1744b87bfb534445c1841ac13d60d Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:33:12 -0300 Subject: [PATCH 5/5] 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;