feat(frontend): add player store + WebAudio Spectrum
This commit is contained in:
parent
f069eef8a8
commit
7ab3c850bf
3 changed files with 132 additions and 0 deletions
104
frontend/src/components/Spectrum.tsx
Normal file
104
frontend/src/components/Spectrum.tsx
Normal 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
18
frontend/src/lib/store.ts
Normal 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);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
10
frontend/src/styles/components/spectrum.css
Normal file
10
frontend/src/styles/components/spectrum.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue