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