diff --git a/frontend/src/components/AudioVisualizer.tsx b/frontend/src/components/AudioVisualizer.tsx index f0e4d1c..89a89cf 100644 --- a/frontend/src/components/AudioVisualizer.tsx +++ b/frontend/src/components/AudioVisualizer.tsx @@ -10,14 +10,17 @@ const MAX_AMPLITUDE = 0.22; const PADDING = 6; export default function AudioVisualizer({ height = 80 }: AudioVisualizerProps) { - const { isPlaying, currentTime, duration } = useAudio(); + const { isPlaying, currentTime, duration, getFrequencyData } = useAudio(); const canvasRef = useRef(null); const rafRef = useRef(0); const heightsRef = useRef(Array(NUM_POINTS).fill(0)); const playingRef = useRef(isPlaying); + const getDataRef = useRef(getFrequencyData); const timeRef = useRef({ currentTime, duration }); + const hasRealDataRef = useRef(false); useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]); + useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]); useEffect(() => { timeRef.current = { currentTime, duration }; }, [currentTime, duration]); useEffect(() => { @@ -45,19 +48,45 @@ export default function AudioVisualizer({ height = 80 }: AudioVisualizerProps) { const animate = () => { if (!ctx || !canvas) return; frame++; + const raw = getDataRef.current?.(); const hts = heightsRef.current; const step = drawW / (NUM_POINTS - 1); const { currentTime: ct, duration: dur } = timeRef.current; const progress = dur > 0 ? ct / dur : 0; const playing = playingRef.current; - // Simulated waveform animation (Web Audio API not used to avoid CORS muting) + // Detect if real frequency data is available + if (raw && playing) { + const sum = raw.reduce((a, b) => a + b, 0); + hasRealDataRef.current = sum > 0; + } + + // Update heights if (playing) { - for (let i = 0; i < NUM_POINTS; i++) { - const wave = Math.sin(frame * 0.04 + i * 0.35) * 0.5 + 0.5; - const noise = Math.sin(frame * 0.07 + i * 1.7) * 0.3 + 0.7; - const target = MAX_AMPLITUDE * 0.6 * (0.5 + wave * noise); - hts[i] += (target - hts[i]) * 0.18; + if (raw && hasRealDataRef.current) { + // Real frequency data from Web Audio API (CORS allowed) + for (let i = 0; i < NUM_POINTS; i++) { + const bucketSize = raw.length / NUM_POINTS; + const startIdx = Math.floor(i * bucketSize); + const endIdx = Math.floor((i + 1) * bucketSize); + let sum = 0; + let count = 0; + for (let j = startIdx; j < endIdx && j < raw.length; j++) { + sum += raw[j]; + count++; + } + const avg = count > 0 ? sum / count : 0; + const target = (avg / 255) * MAX_AMPLITUDE; + hts[i] += (target - hts[i]) * 0.25; + } + } else { + // Simulated visualization (no CORS or no Web Audio) + for (let i = 0; i < NUM_POINTS; i++) { + const wave = Math.sin(frame * 0.04 + i * 0.35) * 0.5 + 0.5; + const noise = Math.sin(frame * 0.07 + i * 1.7) * 0.3 + 0.7; + const target = MAX_AMPLITUDE * 0.6 * (0.5 + wave * noise); + hts[i] += (target - hts[i]) * 0.18; + } } } else { for (let i = 0; i < NUM_POINTS; i++) { diff --git a/frontend/src/context/AudioContext.tsx b/frontend/src/context/AudioContext.tsx index 310194c..97eb25b 100644 --- a/frontend/src/context/AudioContext.tsx +++ b/frontend/src/context/AudioContext.tsx @@ -33,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); @@ -44,6 +47,9 @@ export function AudioProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null); const [userId, setUserId] = useState(null); + // CORS retry: start with CORS for real frequency data, fall back if blocked + const [corsMode, setCorsMode] = useState<'cors' | 'no-cors'>('cors'); + // Refs for interval-based progress saving const currentTimeRef = useRef(0); const durationRef = useRef(0); @@ -104,21 +110,66 @@ export function AudioProvider({ children }: { children: ReactNode }) { }).catch(() => setIsResuming(false)); }, [currentEpisode?._id, userId]); - // getFrequencyData returns null — visualizer uses simulated data - const getFrequencyData = () => null; + // Initialize Web Audio Analyser (only in CORS mode) + useEffect(() => { + if (corsMode !== 'cors' || !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, corsMode]); + + const getFrequencyData = () => { + if (!analyserRef.current || !dataArrayRef.current) return null; + analyserRef.current.getByteFrequencyData(dataArrayRef.current as Uint8Array); + return dataArrayRef.current; + }; // Actions const playEpisode = (episode: Episode) => { + setCorsMode('cors'); + setError(null); setCurrentEpisode(episode); setIsPlaying(true); }; 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); setDuration(0); + setCorsMode('cors'); }; const togglePlay = () => { @@ -151,6 +202,16 @@ export function AudioProvider({ children }: { children: ReactNode }) { setPlaybackSpeedState(prev => speeds[(speeds.indexOf(prev) + 1) % speeds.length]); }; + const handleAudioError = () => { + if (corsMode === 'cors') { + // CORS blocked the audio — retry without CORS (sound works, no real frequency data) + setCorsMode('no-cors'); + setError(null); + } else { + setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé."); + } + }; + return ( audioRef.current && setCurrentTime(audioRef.current.currentTime)} onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)} - onError={() => setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé.")} + onError={handleAudioError} autoPlay /> )} @@ -183,4 +245,4 @@ export function useAudio() { throw new Error('useAudio must be used within an AudioProvider'); } return context; -} +} \ No newline at end of file