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();