feat: reactive audio visualizer centered below inline player

Replace static CSS eq-bars with a real-time AudioVisualizer component
using the Web Audio API AnalyserNode. The visualizer is now centered
below the progress bar in the inline hero player, with 28 bars and
boosted mid-range frequencies for better speech visualization.

- AudioContext: wire AnalyserNode, expose getFrequencyData, add
  crossOrigin="anonymous" and key on the audio element
- AudioPlayer (inline): remove static eq-bars, add AudioVisualizer
- New AudioVisualizer component: requestAnimationFrame loop,
  smooth decay on pause, configurable bar count/height/gap

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-04-22 15:22:55 +02:00
1 parent 35f34d9d44
commit 99c9c299de
3 files changed
+133 -16

No files matched your search

+6 -15
View File
@@ -14,6 +14,7 @@ import {
RotateCw,
} from 'lucide-react';
import AlertModal from './AlertModal';
import AudioVisualizer from './AudioVisualizer';
import { useTheme } from '../context/ThemeContext';
interface AudioPlayerProps {
@@ -127,21 +128,6 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-5xl">🎙️</div>
}
</div>
{/* Barres égaliseur */}
{isPlaying && (
<div
className="absolute -bottom-1 -left-1 flex items-end gap-[3px] px-2 py-1.5 rounded-xl bg-[var(--bg-primary)]/70 backdrop-blur-sm border border-[var(--border-color)]"
aria-hidden="true"
>
{[0, 150, 300, 100].map((delay, i) => (
<span
key={i}
className="block w-[3px] h-4 rounded-full bg-[var(--accent-primary)] animate-eq-bar opacity-90"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</div>
)}
{isPlaying && (
<div className="absolute -bottom-2 -right-2 px-3 py-1 rounded-full bg-[var(--accent-primary)] text-white text-[9px] font-black uppercase tracking-widest shadow-glow-indigo animate-pulse">
En cours
@@ -192,6 +178,11 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
</div>
</div>
{/* Visualiseur audio */}
<div className="w-full flex justify-center my-5">
<AudioVisualizer barCount={28} height={80} gap={3} />
</div>
{/* Controls */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-5">
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { useAudio } from '../context/AudioContext';
interface AudioVisualizerProps {
barCount?: number;
height?: number;
gap?: number;
}
export default function AudioVisualizer({
barCount = 32,
height = 64,
gap = 2,
}: AudioVisualizerProps) {
const { isPlaying, getFrequencyData } = useAudio();
const [heights, setHeights] = useState<number[]>(() => Array(barCount).fill(4));
const rafRef = useRef<number>(0);
const playingRef = useRef(isPlaying);
const getDataRef = useRef(getFrequencyData);
// Keep refs in sync without triggering re-runs
useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]);
useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]);
useEffect(() => {
const animate = () => {
const data = getDataRef.current?.();
if (data && playingRef.current) {
const newHeights = Array.from({ length: barCount }, (_, i) => {
const idx = Math.floor((i / barCount) * data.length);
const value = data[idx] ?? 0;
// Boost mid-range frequencies for better podcast speech visuals
const boost = i > barCount * 0.2 && i < barCount * 0.8 ? 1.15 : 1;
return Math.max(4, Math.min(100, (value / 255) * 100 * boost));
});
setHeights(newHeights);
} else {
// Smooth decay when paused or no data
setHeights(prev =>
prev.map(h => {
const next = h * 0.85;
return next < 4.5 ? 4 : next;
})
);
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [barCount]);
const barWidth = Math.max(2, Math.floor((100 - (barCount - 1) * gap) / barCount));
return (
<div
className="flex items-end justify-center"
style={{ height, gap }}
aria-hidden="true"
>
{heights.map((h, i) => (
<div
key={i}
className="rounded-full bg-gradient-to-t from-[var(--accent-primary)] to-[var(--accent-secondary)] opacity-90"
style={{
width: barWidth,
height: `${h}%`,
transition: playingRef.current ? 'height 60ms linear' : 'height 200ms ease-out',
opacity: 0.5 + (h / 200),
}}
/>
))}
</div>
);
}