diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx
index 9fc48ef..30ebdd3 100644
--- a/frontend/src/components/AudioPlayer.tsx
+++ b/frontend/src/components/AudioPlayer.tsx
@@ -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
:
🎙️
}
- {/* Barres égaliseur */}
- {isPlaying && (
-
- {[0, 150, 300, 100].map((delay, i) => (
-
- ))}
-
- )}
{isPlaying && (
En cours
@@ -192,6 +178,11 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
+ {/* Visualiseur audio */}
+
+
{/* Controls */}
diff --git a/frontend/src/components/AudioVisualizer.tsx b/frontend/src/components/AudioVisualizer.tsx
new file mode 100644
index 0000000..fc2e4d9
--- /dev/null
+++ b/frontend/src/components/AudioVisualizer.tsx
@@ -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
(() => Array(barCount).fill(4));
+ const rafRef = useRef(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 (
+
+ {heights.map((h, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/context/AudioContext.tsx b/frontend/src/context/AudioContext.tsx
index 0c93f86..a20c443 100644
--- a/frontend/src/context/AudioContext.tsx
+++ b/frontend/src/context/AudioContext.tsx
@@ -24,6 +24,7 @@ interface AudioContextType {
changeSpeed: () => void;
setUserId: (id: string | null) => void;
setError: (err: string | null) => void;
+ getFrequencyData: () => Uint8Array | null;
// Legacy (kept for compatibility)
setIsPlaying: (playing: boolean) => void;
}
@@ -32,6 +33,9 @@ const AudioContext = createContext(undefined);
export function AudioProvider({ children }: { children: ReactNode }) {
const audioRef = useRef(null);
+ const webAudioRef = useRef(null);
+ const analyserRef = useRef(null);
+ const dataArrayRef = useRef(null);
const [currentEpisode, setCurrentEpisode] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@@ -103,6 +107,45 @@ export function AudioProvider({ children }: { children: ReactNode }) {
}).catch(() => setIsResuming(false));
}, [currentEpisode?._id, userId]);
+ // Initialize Web Audio Analyser when audio element is available
+ useEffect(() => {
+ if (!audioRef.current || !currentEpisode) return;
+
+ try {
+ const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext;
+ if (!AudioCtx) return;
+
+ const ctx = new AudioCtx();
+ const source = ctx.createMediaElementSource(audioRef.current);
+ const analyser = ctx.createAnalyser();
+ analyser.fftSize = 128;
+ analyser.smoothingTimeConstant = 0.8;
+ source.connect(analyser);
+ analyser.connect(ctx.destination);
+
+ webAudioRef.current = ctx;
+ analyserRef.current = analyser;
+ dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
+ } catch {
+ // Fallback: audio plays without visualizer
+ }
+
+ return () => {
+ if (webAudioRef.current && webAudioRef.current.state !== 'closed') {
+ webAudioRef.current.close().catch(() => {});
+ }
+ analyserRef.current = null;
+ dataArrayRef.current = null;
+ webAudioRef.current = null;
+ };
+ }, [currentEpisode?._id]);
+
+ const getFrequencyData = () => {
+ if (!analyserRef.current || !dataArrayRef.current) return null;
+ analyserRef.current.getByteFrequencyData(dataArrayRef.current);
+ return dataArrayRef.current;
+ };
+
// Actions
const playEpisode = (episode: Episode) => {
setCurrentEpisode(episode);
@@ -111,6 +154,12 @@ export function AudioProvider({ children }: { children: ReactNode }) {
const closePlayer = () => {
if (audioRef.current) audioRef.current.pause();
+ if (webAudioRef.current && webAudioRef.current.state !== 'closed') {
+ webAudioRef.current.close().catch(() => {});
+ }
+ analyserRef.current = null;
+ dataArrayRef.current = null;
+ webAudioRef.current = null;
setCurrentEpisode(null);
setIsPlaying(false);
setCurrentTime(0);
@@ -151,13 +200,15 @@ export function AudioProvider({ children }: { children: ReactNode }) {
{/* Persistent audio element — lives at provider level so it survives page navigation */}
{currentEpisode && (