'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useAudio } from './AudioProvider'; interface WaveformPlayerProps { // Audio source URL (required) src: string; // Audio playback options onPlay?: () => void; onFullyPlayed?: () => void; // Display options showWaveform?: boolean; // defaults to true; if false, shows simple player button playMode?: 'pause' | 'stop'; // defaults to 'pause'; determines play/pause vs play/stop behavior } const BAR_COUNT = 120; export default function WaveformPlayer({ src, onPlay, onFullyPlayed, showWaveform = true, playMode = 'pause', }: WaveformPlayerProps) { const { currentAudioId, setCurrentAudio, playerCount, registerPlayer, unregisterPlayer } = useAudio(); // Generate unique ID for this player instance const playerId = useRef(Math.random().toString(36).slice(2)).current; const isActive = currentAudioId === playerId; const isOnlyPlayer = playerCount === 1; const audioRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); const fullyPlayedRef = useRef(false); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [peaks, setPeaks] = useState([]); // Register/unregister player on mount/unmount useEffect(() => { registerPlayer(playerId); return () => { unregisterPlayer(playerId); }; }, [playerId, registerPlayer, unregisterPlayer]); // Stop if another player became active useEffect(() => { if (!isActive && isPlaying) { audioRef.current?.pause(); cancelAnimationFrame(rafRef.current); setIsPlaying(false); } }, [isActive, isPlaying]); // Reset state when src changes useEffect(() => { setPeaks([]); setCurrentTime(0); setDuration(0); fullyPlayedRef.current = false; if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; } cancelAnimationFrame(rafRef.current); setIsPlaying(false); }, [src]); const tick = useCallback(() => { const audio = audioRef.current; if (!audio) return; setCurrentTime(audio.currentTime); rafRef.current = requestAnimationFrame(tick); }, []); const handlePlay = useCallback(() => { const audio = audioRef.current; if (!audio) return; setCurrentAudio(playerId); onPlay?.(); // Only reset to start if in stop mode; in pause mode, resume from paused position if (playMode === 'stop') { audio.currentTime = 0; } audio.play(); setIsPlaying(true); rafRef.current = requestAnimationFrame(tick); }, [playerId, onPlay, tick, playMode]); const handlePause = useCallback(() => { const audio = audioRef.current; if (!audio) return; audio.pause(); setIsPlaying(false); cancelAnimationFrame(rafRef.current); }, []); const handleStop = useCallback(() => { const audio = audioRef.current; if (!audio) return; audio.pause(); audio.currentTime = 0; setCurrentAudio(null); setIsPlaying(false); cancelAnimationFrame(rafRef.current); }, []); const handleEnded = useCallback(() => { setIsPlaying(false); cancelAnimationFrame(rafRef.current); if (!fullyPlayedRef.current) { fullyPlayedRef.current = true; onFullyPlayed?.(); } }, [onFullyPlayed]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.code === 'Space' && isOnlyPlayer) { e.preventDefault(); if (isPlaying) { if (playMode === 'stop') { handleStop(); } else { handlePause(); } } else { handlePlay(); } } }, [isOnlyPlayer, isPlaying, playMode, handlePlay, handlePause, handleStop] ); const handleLoadedMetadata = useCallback(() => { if (audioRef.current) setDuration(audioRef.current.duration); }, []); // Add spacebar listener when only one player exists useEffect(() => { if (!isOnlyPlayer) return; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [isOnlyPlayer, handleKeyDown]); // Decode audio and generate waveform peaks (only if showWaveform is true) useEffect(() => { if (!showWaveform) return; let cancelled = false; (async () => { try { const res = await fetch(src); const buf = await res.arrayBuffer(); const actx = new AudioContext(); const decoded = await actx.decodeAudioData(buf); if (cancelled) return; const data = decoded.getChannelData(0); const blockSize = Math.floor(data.length / BAR_COUNT); const raw: number[] = []; for (let i = 0; i < BAR_COUNT; i++) { let sum = 0; for (let j = 0; j < blockSize; j++) { sum += Math.abs(data[i * blockSize + j]); } raw.push(sum / blockSize); } const max = Math.max(...raw, 0.001); if (!cancelled) setPeaks(raw.map((v) => v / max)); await actx.close(); } catch { if (!cancelled) setPeaks(Array(BAR_COUNT).fill(0.5)); } })(); return () => { cancelled = true; }; }, [src, showWaveform]); // Reset peaks when src changes useEffect(() => { setPeaks([]); }, [src]); // Draw waveform on canvas whenever peaks or playback position change useEffect(() => { if (!showWaveform) return; const canvas = canvasRef.current; if (!canvas || peaks.length === 0) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const { width, height } = canvas; ctx.clearRect(0, 0, width, height); const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; const barW = Math.max(1, width / BAR_COUNT - 1); peaks.forEach((peak, i) => { const x = (i / BAR_COUNT) * width; const barH = Math.max(2, peak * height * 0.85); const y = (height - barH) / 2; const played = i / BAR_COUNT < progress; ctx.fillStyle = played ? '#3b82f6' : '#d1d5db'; ctx.beginPath(); ctx.roundRect(x, y, barW, barH, 2); ctx.fill(); }); }, [peaks, currentTime, duration, showWaveform]); const fmt = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${sec.toString().padStart(2, '0')}`; }; // Simple player button (no waveform) if (!showWaveform) { return (
); } // Waveform player with visualization return (
); }