feat(frontend): add player store + WebAudio Spectrum

This commit is contained in:
devilreef 2026-04-30 09:36:35 +06:00
parent f069eef8a8
commit 7ab3c850bf
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
3 changed files with 132 additions and 0 deletions

View file

@ -0,0 +1,104 @@
import { useEffect, useRef } from 'react';
import { playerStore } from '@lib/store';
const BARS = 36;
export function Spectrum() {
const containerRef = useRef<HTMLDivElement | null>(null);
const barRefs = useRef<HTMLDivElement[]>([]);
useEffect(() => {
let raf = 0;
let ctx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let buf: Uint8Array<ArrayBuffer> | null = null;
let connected = false;
const tryConnect = () => {
if (connected) return;
const audio = playerStore.getAudio();
if (!audio) return;
try {
if (!ctx) {
const Ctor = window.AudioContext
?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) throw new Error('no AudioContext');
ctx = new Ctor();
}
const src = ctx.createMediaElementSource(audio);
analyser = ctx.createAnalyser();
analyser.fftSize = 128;
src.connect(analyser);
analyser.connect(ctx.destination);
buf = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
connected = true;
} catch (err) {
console.warn('[spectrum] webaudio unavailable, using fake', err);
}
};
const fakeFrame = (t: number) => {
for (let i = 0; i < BARS; i++) {
const a = Math.sin(t * 0.0021 + i * 0.7) * 0.5 + 0.5;
const f = i / BARS;
const env = 0.4 + 0.6 * Math.exp(-f * 2.2);
const v = a * env * 0.3;
const el = barRefs.current[i];
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
}
};
const realFrame = () => {
if (!analyser || !buf) return;
analyser.getByteFrequencyData(buf);
for (let i = 0; i < BARS; i++) {
const v = (buf[i] ?? 0) / 255;
const el = barRefs.current[i];
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
}
};
const tick = (t: number) => {
tryConnect();
const audio = playerStore.getAudio();
if (connected && audio && !audio.paused) {
if (ctx?.state === 'suspended') {
void ctx.resume();
}
realFrame();
} else {
fakeFrame(t);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const unsub = playerStore.subscribe(() => {
tryConnect();
});
return () => {
cancelAnimationFrame(raf);
unsub();
try {
void ctx?.close();
} catch {
// ignore
}
};
}, []);
return (
<div className="deck-spectrum" ref={containerRef}>
{Array.from({ length: BARS }).map((_, i) => (
<div
key={i}
className="spec-bar"
ref={(el) => {
if (el) barRefs.current[i] = el;
}}
/>
))}
</div>
);
}

18
frontend/src/lib/store.ts Normal file
View file

@ -0,0 +1,18 @@
let audioRef: HTMLAudioElement | null = null;
const subs = new Set<() => void>();
export const playerStore = {
setAudio(el: HTMLAudioElement | null) {
audioRef = el;
subs.forEach((fn) => fn());
},
getAudio(): HTMLAudioElement | null {
return audioRef;
},
subscribe(fn: () => void): () => void {
subs.add(fn);
return () => {
subs.delete(fn);
};
},
};

View file

@ -0,0 +1,10 @@
.deck-spectrum {
display: flex; align-items: flex-end; gap: 2px;
height: 80px; margin-top: 18px;
padding: 10px; background: var(--ink); border: 2px solid var(--ink);
}
.spec-bar {
flex: 1; min-height: 3px;
background: linear-gradient(180deg, var(--pink) 0%, var(--lemon) 50%, var(--green) 100%);
transition: height 80ms linear;
}