diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html
index 73cfdf4..022bb1d 100644
--- a/plugin/contents/ui/dancer.html
+++ b/plugin/contents/ui/dancer.html
@@ -96,6 +96,54 @@ html, body {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
+@keyframes horizonBeat {
+ 0% { opacity: 1; box-shadow: 0 0 60px var(--theme-color, rgba(0,220,255,1)), 0 0 140px var(--theme-color, rgba(0,200,255,0.6)), 0 0 8px #fff; filter: brightness(2.5); }
+ 100% { opacity: 0.75; box-shadow: 0 0 25px rgba(0,220,255,0.8), 0 0 80px rgba(0,200,255,0.25); filter: brightness(1); }
+}
+.horizon.beat {
+ animation: horizonBeat 0.45s ease-out forwards;
+}
+
+/* ============================================================
+ SPECTRUM VISUALIZER (behind character)
+ ============================================================ */
+.spectrum {
+ position: absolute;
+ bottom: 32%;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.8s;
+ mix-blend-mode: screen;
+}
+.spec-bar {
+ width: 6px;
+ border-radius: 3px 3px 0 0;
+ height: 3px;
+ transition: height 0.05s linear;
+}
+
+/* ============================================================
+ METEORS
+ ============================================================ */
+.meteor {
+ position: absolute;
+ width: 2px;
+ border-radius: 1px;
+ pointer-events: none;
+ transform-origin: top center;
+ animation: meteorFall linear forwards;
+}
+@keyframes meteorFall {
+ 0% { opacity: 0; }
+ 5% { opacity: 1; }
+ 85% { opacity: 1; }
+ 100% { opacity: 0; }
+}
/* ============================================================
VU METER (moved up)
@@ -714,6 +762,9 @@ html, body {
@@ -867,6 +918,63 @@ window.addEventListener('resize', resizeFloor);
resizeFloor();
requestAnimationFrame(drawFloor);
+/* ============================================================
+ SPECTRUM VISUALIZER
+ ============================================================ */
+const SPEC_COUNT = 48;
+const specEl = document.getElementById('spectrum');
+const specBars = [];
+const SPEC_PALETTE = [
+ '#ff0099','#ff00cc','#cc00ff','#9900ff','#6600ff',
+ '#0033ff','#0066ff','#0099ff','#00ccff','#00ffff',
+ '#00ffcc','#00ff99','#00ff66','#00ff33',
+ '#33ff00','#66ff00','#99ff00','#ccff00',
+ '#ffff00','#ffcc00','#ff9900','#ff6600','#ff3300','#ff0000',
+];
+
+for (let i = 0; i < SPEC_COUNT; i++) {
+ const b = document.createElement('div');
+ b.className = 'spec-bar';
+ const c = SPEC_PALETTE[Math.round(i / SPEC_COUNT * (SPEC_PALETTE.length - 1))];
+ b.style.background = c;
+ b.style.boxShadow = `0 0 6px ${c}`;
+ specEl.appendChild(b);
+ specBars.push(b);
+}
+
+/* ============================================================
+ METEORS
+ ============================================================ */
+const scene = document.querySelector('.scene');
+
+function spawnMeteor() {
+ const el = document.createElement('div');
+ el.className = 'meteor';
+ const x = Math.random() * 110 - 5; // % from left (can go off-edge)
+ const angle = 25 + Math.random() * 20; // degrees
+ const len = 80 + Math.random() * 140; // px
+ const dur = 1.2 + Math.random() * 1.8; // seconds
+ const color = `rgba(${180+Math.random()*75|0},${180+Math.random()*75|0},255,0.9)`;
+ el.style.cssText = `
+ left:${x}%; top:${-len}px;
+ height:${len}px;
+ background:linear-gradient(to bottom, transparent, ${color});
+ transform:rotate(${angle}deg);
+ animation-duration:${dur}s;
+ animation-name:meteorFall;
+ translate:0 ${(window.innerHeight + len) * 1.3}px;
+ `;
+ scene.appendChild(el);
+ setTimeout(() => el.remove(), dur * 1000);
+}
+
+// Spawn 1-2 meteors every 4-10 seconds
+(function meteorLoop() {
+ spawnMeteor();
+ if (Math.random() < 0.3) setTimeout(spawnMeteor, 300);
+ setTimeout(meteorLoop, 4000 + Math.random() * 6000);
+})();
+
/* ============================================================
@@ -906,18 +1014,25 @@ const BASS_BARS = 4;
const BEAT_THRESH = 1.5;
const BEAT_COOLDOWN = 280;
let bassSmooth = 0, bassEma = 0, lastBeat = 0;
-const beatWrap = document.querySelector('.beat-wrap');
+const beatWrap = document.querySelector('.beat-wrap');
+const horizonEl = document.querySelector('.horizon');
+
+function triggerBeat() {
+ [beatWrap, horizonEl].forEach(el => {
+ el.classList.remove('beat');
+ void el.offsetWidth;
+ el.classList.add('beat');
+ });
+}
function detectBeat(vals) {
const bass = vals.slice(0, BASS_BARS).reduce((s, v) => s + v, 0) / BASS_BARS;
- bassSmooth = bassSmooth * 0.6 + bass * 0.4; // fast smooth
- bassEma = bassEma * 0.96 + bass * 0.04; // slow background
+ bassSmooth = bassSmooth * 0.6 + bass * 0.4;
+ bassEma = bassEma * 0.96 + bass * 0.04;
const now = performance.now();
if (bassSmooth > bassEma * BEAT_THRESH && bassSmooth > 8 && now - lastBeat > BEAT_COOLDOWN) {
lastBeat = now;
- beatWrap.classList.remove('beat');
- void beatWrap.offsetWidth; // force reflow to restart animation
- beatWrap.classList.add('beat');
+ triggerBeat();
}
}
@@ -926,12 +1041,45 @@ function updateBars(raw) {
if (!isPlaying) return;
const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
detectBeat(vals);
+
+ // VU meter (low bars)
vuBars.forEach((bar, i) => {
- const v = vals[i] || 0;
- const h = Math.max(3, (v / 100) * MAX_BAR_H);
- bar.style.height = h + 'px';
+ const v = vals[i] || 0;
+ bar.style.height = Math.max(3, (v / 100) * MAX_BAR_H) + 'px';
bar.style.opacity = 0.3 + 0.7 * (v / 100);
});
+
+ // Spectrum (mid-to-high frequencies, remapped to SPEC_COUNT bars)
+ specBars.forEach((bar, i) => {
+ const srcIdx = Math.round(4 + (i / SPEC_COUNT) * (vals.length - 5));
+ const v = vals[srcIdx] || 0;
+ bar.style.height = Math.max(3, (v / 100) * window.innerHeight * 0.55) + 'px';
+ });
+}
+
+/* ============================================================
+ MUSIC-LIGHT THEME (album color)
+ ============================================================ */
+function applyTheme(rgb) {
+ const [r, g, b] = rgb;
+ const color = `rgba(${r},${g},${b},1)`;
+ document.documentElement.style.setProperty('--theme-color', color);
+ // Tint horizon
+ horizonEl.style.background = `linear-gradient(to right,
+ transparent 0%,
+ rgba(${r},${g},${b},0.7) 20%,
+ rgba(${r},${g},${b},1) 50%,
+ rgba(${r},${g},${b},0.7) 80%,
+ transparent 100%)`;
+ horizonEl.style.boxShadow =
+ `0 0 30px rgba(${r},${g},${b},0.9), 0 0 90px rgba(${r},${g},${b},0.3)`;
+}
+
+function fetchTheme() {
+ fetch('http://127.0.0.1:8765/current')
+ .then(r => r.json())
+ .then(d => { if (d.rgb) applyTheme(d.rgb); })
+ .catch(() => {});
}
// Connect to CAVA SSE server (started by cava-start.sh via QML)
@@ -985,7 +1133,9 @@ function updateMusic(artist, title) {
isPlaying = playing;
document.querySelector('.robot-wrap').style.opacity = playing ? '1' : '0';
document.querySelector('.music-ticker').style.opacity = playing ? '1' : '0';
+ specEl.style.opacity = playing ? '0.45' : '0';
if (!playing) vuBars.forEach(b => { b.style.height = '3px'; b.style.opacity = '0.15'; });
+ if (playing) fetchTheme();
if (el.textContent === text) return;
el.textContent = text;
if (playing) pickSkeleton();