mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-17 04:09:15 +02:00
fix: onde réelle quand CORS dispo, son + simulation sinon
Approche hybride CORS retry: - Tentative 1: crossOrigin="anonymous" + createMediaElementSource → données de fréquence réelles si le serveur le permet - Tentative 2 (si CORS bloque): sans crossOrigin, audio direct → son fonctionne, visualiseur simulé Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a90f77d65
commit
3d73ed1e62
2 files changed
+103
-12
No files matched your search
@@ -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<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const heightsRef = useRef<number[]>(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++) {
|
||||
|
||||
@@ -33,6 +33,9 @@ const AudioContext = createContext<AudioContextType | undefined>(undefined);
|
||||
|
||||
export function AudioProvider({ children }: { children: ReactNode }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const webAudioRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const dataArrayRef = useRef<Uint8Array | null>(null);
|
||||
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(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<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(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<ArrayBuffer>);
|
||||
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 (
|
||||
<AudioContext.Provider value={{
|
||||
currentEpisode, isPlaying, currentTime, duration, volume, playbackSpeed, isMuted, isResuming, error,
|
||||
@@ -160,15 +221,16 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||
{/* Persistent audio element — lives at provider level so it survives page navigation */}
|
||||
{currentEpisode && (
|
||||
<audio
|
||||
key={currentEpisode._id}
|
||||
key={`${currentEpisode._id}-${corsMode}`}
|
||||
ref={audioRef}
|
||||
src={currentEpisode.audioUrl}
|
||||
crossOrigin={corsMode === 'cors' ? 'anonymous' : undefined}
|
||||
onTimeUpdate={() => 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user