import { createContext, useContext, useState, useRef, useEffect, ReactNode } from 'react'; import { Episode, episodeService } from '../services/episodeService'; interface AudioContextType { // State currentEpisode: Episode | null; isPlaying: boolean; currentTime: number; duration: number; volume: number; playbackSpeed: number; isMuted: boolean; isResuming: boolean; error: string | null; // Actions playEpisode: (episode: Episode) => void; closePlayer: () => void; togglePlay: () => void; seek: (time: number) => void; seekRelative: (delta: number) => void; setVolume: (v: number) => void; toggleMute: () => void; changeSpeed: () => void; setUserId: (id: string | null) => void; setError: (err: string | null) => void; getFrequencyData: () => Uint8Array | null; // Legacy (kept for compatibility) setIsPlaying: (playing: boolean) => void; } 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 savedPositionRef = useRef(null); const [currentEpisode, setCurrentEpisode] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolumeState] = useState(() => parseFloat(localStorage.getItem('podcastic-volume') || '1')); const [playbackSpeed, setPlaybackSpeedState] = useState(() => parseFloat(localStorage.getItem('podcastic-speed') || '1')); const [isMuted, setIsMuted] = useState(false); const [isResuming, setIsResuming] = useState(false); const [error, setError] = useState(null); const [userId, setUserId] = useState(null); // Audio source mode: direct CORS → backend proxy (always CORS) → give up const [corsMode, setCorsMode] = useState<'cors' | 'proxy' | 'no-cors'>('cors'); // Refs for interval-based progress saving const currentTimeRef = useRef(0); const durationRef = useRef(0); const episodeIdRef = useRef(null); const userIdRef = useRef(null); useEffect(() => { currentTimeRef.current = currentTime; }, [currentTime]); useEffect(() => { durationRef.current = duration; }, [duration]); useEffect(() => { episodeIdRef.current = currentEpisode?._id || null; }, [currentEpisode]); useEffect(() => { userIdRef.current = userId; }, [userId]); // Apply volume / speed to the audio element useEffect(() => { if (audioRef.current) { audioRef.current.volume = isMuted ? 0 : volume; audioRef.current.playbackRate = playbackSpeed; } }, [volume, isMuted, playbackSpeed, currentEpisode]); // Persist volume / speed to localStorage useEffect(() => { localStorage.setItem('podcastic-volume', volume.toString()); localStorage.setItem('podcastic-speed', playbackSpeed.toString()); }, [volume, playbackSpeed]); // Periodic + unmount progress saving. Considered completed at >= 90%. useEffect(() => { if (!currentEpisode || !userId) return; const saveNow = () => { if (!episodeIdRef.current || !userIdRef.current) return; const pos = Math.floor(currentTimeRef.current); const d = durationRef.current; const completed = d > 0 && (pos / d) >= 0.9; episodeService.saveProgress(episodeIdRef.current, pos, completed).catch(() => {}); }; const interval = setInterval(() => { if (isPlaying) saveNow(); }, 20000); return () => { clearInterval(interval); saveNow(); }; }, [currentEpisode?._id, userId, isPlaying]); // Resume at previous position: store in ref, applied on loadedmetadata // (handles the case where CORS failure remounts the audio element) useEffect(() => { if (!currentEpisode || !userId) return; savedPositionRef.current = null; setIsResuming(true); episodeService.getProgress(currentEpisode._id).then(r => { const pos = r.progress?.position; if (pos && pos > 5) { savedPositionRef.current = pos; if (audioRef.current && audioRef.current.readyState >= 1) { audioRef.current.currentTime = pos; setCurrentTime(pos); savedPositionRef.current = null; } } setIsResuming(false); }).catch(() => setIsResuming(false)); }, [currentEpisode?._id, userId]); // Initialize Web Audio Analyser (CORS or proxy mode — both have CORS headers) useEffect(() => { if (corsMode === 'no-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 = 256; analyser.smoothingTimeConstant = 0.55; 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 = () => { if (!audioRef.current) return; if (audioRef.current.paused) audioRef.current.play(); else audioRef.current.pause(); }; const seek = (time: number) => { if (!audioRef.current) return; audioRef.current.currentTime = time; setCurrentTime(time); }; const seekRelative = (delta: number) => { if (!audioRef.current) return; const t = Math.max(0, Math.min(duration, audioRef.current.currentTime + delta)); seek(t); }; const setVolume = (v: number) => { setVolumeState(v); setIsMuted(v === 0); }; const toggleMute = () => setIsMuted(m => !m); const changeSpeed = () => { const speeds = [1, 1.25, 1.5, 2, 0.75]; setPlaybackSpeedState(prev => speeds[(speeds.indexOf(prev) + 1) % speeds.length]); }; const handleAudioError = () => { if (corsMode === 'cors') { // Direct CORS failed — retry via backend proxy (adds CORS headers, Web Audio works) setCorsMode('proxy'); setError(null); } else if (corsMode === 'proxy') { // Proxy also failed — last resort: plain audio, no Web Audio analysis setCorsMode('no-cors'); setError(null); } else { setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé."); } }; return ( {/* Persistent audio element — lives at provider level so it survives page navigation */} {currentEpisode && ( ); } export function useAudio() { const context = useContext(AudioContext); if (context === undefined) { throw new Error('useAudio must be used within an AudioProvider'); } return context; }