diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html
index 987f54a..3ba1740 100644
--- a/plugin/contents/ui/dancer.html
+++ b/plugin/contents/ui/dancer.html
@@ -217,6 +217,19 @@ html, body {
transition: opacity 0.8s;
}
+.beat-wrap {
+ display: block;
+ transform-origin: center bottom;
+}
+@keyframes beatBounce {
+ 0% { transform: scale(1.08) translateY(-10px); filter: brightness(1.4); }
+ 55% { transform: scale(0.96) translateY(4px); filter: brightness(1); }
+ 100% { transform: scale(1) translateY(0); filter: brightness(1); }
+}
+.beat-wrap.beat {
+ animation: beatBounce 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
+}
+
.skeleton-gif {
display: block;
height: 40vh;
@@ -703,8 +716,10 @@ html, body {
-

-
+
+

+
+
@@ -886,12 +901,33 @@ for (let i = 0; i < VU_COUNT; i++) {
vuBars.push(bar);
}
+// Beat detection
+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');
+
+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
+ 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');
+ }
+}
+
// Called with semicolon-separated CAVA values (0–100 per bar)
function updateBars(raw) {
if (!isPlaying) return;
- const vals = raw.split(';');
+ const vals = raw.split(';').map(v => Math.min(100, parseInt(v, 10) || 0));
+ detectBeat(vals);
vuBars.forEach((bar, i) => {
- const v = Math.min(100, parseInt(vals[i] || 0, 10));
+ const v = vals[i] || 0;
const h = Math.max(3, (v / 100) * MAX_BAR_H);
bar.style.height = h + 'px';
bar.style.opacity = 0.3 + 0.7 * (v / 100);