mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
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:
1 parent
35f34d9d44
commit
99c9c299de
3 files changed
+133
-16
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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);
|
||||
@@ -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 }) {
|
||||
<AudioContext.Provider value={{
|
||||
currentEpisode, isPlaying, currentTime, duration, volume, playbackSpeed, isMuted, isResuming, error,
|
||||
playEpisode, closePlayer, togglePlay, seek, seekRelative, setVolume, toggleMute, changeSpeed,
|
||||
setUserId, setError, setIsPlaying,
|
||||
setUserId, setError, getFrequencyData, setIsPlaying,
|
||||
}}>
|
||||
{/* Persistent audio element — lives at provider level so it survives page navigation */}
|
||||
{currentEpisode && (
|
||||
<audio
|
||||
key={currentEpisode._id}
|
||||
ref={audioRef}
|
||||
src={currentEpisode.audioUrl}
|
||||
crossOrigin="anonymous"
|
||||
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
|
||||
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
|
||||
Reference in new issue
Block a user