import { useRef, useState, useEffect, useCallback } from 'react'; import { Play, Pause, Volume1, Volume2, VolumeX } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { cn } from '@/lib/utils'; import { usePlayerControls } from '@/hooks/usePlayerControls'; import { formatTime } from '@/lib/formatTime'; interface AudioVisualizerProps { src: string; mime?: string; /** Avatar image URL for the circle in the centre */ avatarUrl?: string; /** Fallback display letter for the avatar */ avatarFallback?: string; className?: string; } /** * Audio player that renders identically to VideoPlayer — same container, * same overlay controls, same progress bar — but the "video surface" is * a canvas showing an animated sinewave with the author's avatar centred. */ export function AudioVisualizer({ src, mime, avatarUrl, avatarFallback = '?', className, }: AudioVisualizerProps) { const audioRef = useRef(null); const canvasRef = useRef(null); const progressRef = useRef(null); const containerRef = useRef(null); const animFrameRef = useRef(0); const analyserRef = useRef(null); const audioCtxRef = useRef(null); const idlePhaseRef = useRef(0); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [hasStarted, setHasStarted] = useState(false); const progress = duration > 0 ? (currentTime / duration) * 100 : 0; const { showControls, revealControls, scheduleHide, isMuted, volume, toggleMute, handleVolumeChange } = usePlayerControls({ mediaRef: audioRef, containerRef, isPlaying, }); // ── Canvas: sinewave drawing ─────────────────────────────────────────── const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Use CSS pixel dimensions for drawing coordinates const W = canvas.clientWidth; const H = canvas.clientHeight; const dpr = window.devicePixelRatio || 1; // Ensure backing store matches display size if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) { canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); } ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H); // Read the theme's --primary HSL value at draw time so the wave always // matches the current color scheme (light / dark / custom theme). const primaryHsl = getComputedStyle(canvas).getPropertyValue('--primary').trim(); const primaryColor = `hsl(${primaryHsl})`; const primaryFaint = `hsl(${primaryHsl} / 0.15)`; const primaryMid = `hsl(${primaryHsl} / 0.85)`; const primaryGlow = `hsl(${primaryHsl} / 0.55)`; const analyser = analyserRef.current; let dataArray: Uint8Array | null = null; if (analyser && isPlaying) { dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteTimeDomainData(dataArray); } // Avatar clear-zone radius in CSS px (matches the size-20 = 80px avatar) const avatarR = 52; // half of size-20 (80px) + a small gap const cx = W / 2; const midY = H / 2; const grad = ctx.createLinearGradient(0, 0, W, 0); grad.addColorStop(0, primaryFaint); grad.addColorStop(0.35, primaryMid); grad.addColorStop(0.5, primaryColor); grad.addColorStop(0.65, primaryMid); grad.addColorStop(1, primaryFaint); ctx.beginPath(); ctx.strokeStyle = grad; ctx.lineWidth = 2.5; ctx.shadowBlur = 10; ctx.shadowColor = primaryGlow; if (dataArray) { // Real-time waveform const sliceW = W / dataArray.length; for (let i = 0; i < dataArray.length; i++) { const x = i * sliceW; const v = dataArray[i] / 128.0 - 1; // -1..1 const dist = Math.abs(x - cx); // Fade smoothly through the avatar zone const fade = dist < avatarR ? 0 : dist < avatarR * 1.6 ? (dist - avatarR) / (avatarR * 0.6) : 1; const y = midY + v * (H * 0.35) * fade; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } } else { // Idle animated sine idlePhaseRef.current += 0.03; const phase = idlePhaseRef.current; const steps = 300; for (let i = 0; i <= steps; i++) { const x = (i / steps) * W; const dist = Math.abs(x - cx); const fade = dist < avatarR ? 0 : dist < avatarR * 1.6 ? (dist - avatarR) / (avatarR * 0.6) : 1; const y = midY + Math.sin((i / steps) * Math.PI * 4 + phase) * (H * 0.12) * fade; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } } ctx.stroke(); ctx.restore(); animFrameRef.current = requestAnimationFrame(draw); }, [isPlaying]); useEffect(() => { cancelAnimationFrame(animFrameRef.current); animFrameRef.current = requestAnimationFrame(draw); return () => cancelAnimationFrame(animFrameRef.current); }, [draw]); // ── Web Audio API ────────────────────────────────────────────────────── const ensureAudioContext = useCallback(() => { const audio = audioRef.current; if (!audio || audioCtxRef.current) return; const actx = new AudioContext(); audioCtxRef.current = actx; const analyser = actx.createAnalyser(); analyser.fftSize = 2048; analyserRef.current = analyser; actx.createMediaElementSource(audio).connect(analyser); analyser.connect(actx.destination); }, []); // ── Playback controls ────────────────────────────────────────────────── const togglePlay = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const audio = audioRef.current; if (!audio) return; ensureAudioContext(); if (audioCtxRef.current?.state === 'suspended') audioCtxRef.current.resume(); if (audio.paused) { audio.play(); } else { audio.pause(); } }, [ensureAudioContext]); const handleSeek = (e: React.MouseEvent) => { e.stopPropagation(); const audio = audioRef.current; const bar = progressRef.current; if (!audio || !bar || !duration) return; const rect = bar.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); audio.currentTime = ratio * duration; }; const handleCanvasClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!hasStarted) { const audio = audioRef.current; if (audio) { ensureAudioContext(); audio.play(); } return; } togglePlay(e); revealControls(); }; // ── Audio event listeners ────────────────────────────────────────────── useEffect(() => { const audio = audioRef.current; if (!audio) return; const onPlay = () => { setIsPlaying(true); setHasStarted(true); }; const onPause = () => setIsPlaying(false); const onEnded = () => setIsPlaying(false); const onTime = () => setCurrentTime(audio.currentTime); const onDur = () => setDuration(audio.duration); audio.addEventListener('play', onPlay); audio.addEventListener('pause', onPause); audio.addEventListener('ended', onEnded); audio.addEventListener('timeupdate', onTime); audio.addEventListener('durationchange', onDur); audio.addEventListener('loadedmetadata', onDur); return () => { audio.removeEventListener('play', onPlay); audio.removeEventListener('pause', onPause); audio.removeEventListener('ended', onEnded); audio.removeEventListener('timeupdate', onTime); audio.removeEventListener('durationchange', onDur); audio.removeEventListener('loadedmetadata', onDur); }; }, []); useEffect(() => () => { cancelAnimationFrame(animFrameRef.current); audioCtxRef.current?.close(); }, []); return (
{ if (isPlaying) scheduleHide(); }} onClick={(e) => e.stopPropagation()} > {/* Hidden audio element */} {/* Sinewave canvas — fills the entire box */} {/* Avatar — centred over the canvas */}
{avatarFallback}
{/* Big centred play button before first play — identical to VideoPlayer */} {!hasStarted && (
)} {/* Bottom control bar — identical markup to VideoPlayer */} {hasStarted && (
{/* Progress bar */}
{/* Controls row */}
{/* Volume: icon toggles mute, slider sets level */}
e.stopPropagation()} aria-label="Volume" className={cn( 'w-0 opacity-0 group-hover/vol:w-16 group-hover/vol:opacity-100', 'transition-all duration-200 cursor-pointer accent-white h-1', )} />
{formatTime(currentTime)} / {formatTime(duration)}
)}
); }