From 7ab3c850bf6f151ee5d35c2239ec50d6fb41dc2c Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 09:36:35 +0600 Subject: [PATCH] feat(frontend): add player store + WebAudio Spectrum --- frontend/src/components/Spectrum.tsx | 104 ++++++++++++++++++++ frontend/src/lib/store.ts | 18 ++++ frontend/src/styles/components/spectrum.css | 10 ++ 3 files changed, 132 insertions(+) create mode 100644 frontend/src/components/Spectrum.tsx create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/styles/components/spectrum.css diff --git a/frontend/src/components/Spectrum.tsx b/frontend/src/components/Spectrum.tsx new file mode 100644 index 0000000..27d9c6e --- /dev/null +++ b/frontend/src/components/Spectrum.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react'; +import { playerStore } from '@lib/store'; + +const BARS = 36; + +export function Spectrum() { + const containerRef = useRef(null); + const barRefs = useRef([]); + + useEffect(() => { + let raf = 0; + let ctx: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let buf: Uint8Array | 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; + 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 ( +
+ {Array.from({ length: BARS }).map((_, i) => ( +
{ + if (el) barRefs.current[i] = el; + }} + /> + ))} +
+ ); +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..09b6ff2 --- /dev/null +++ b/frontend/src/lib/store.ts @@ -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); + }; + }, +}; diff --git a/frontend/src/styles/components/spectrum.css b/frontend/src/styles/components/spectrum.css new file mode 100644 index 0000000..d9d8f24 --- /dev/null +++ b/frontend/src/styles/components/spectrum.css @@ -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; +}