import React, { useState, useRef, useEffect, useCallback } from "react"; import { Play, Pause } from "lucide-react"; interface AudioPlayerProps { /** Audio source URL. If not provided, onLoadRequest must be provided. */ src?: string; /** Called when play is clicked and no src is loaded yet. Should return the audio URL. */ onLoadRequest?: () => Promise; className?: string; autoPlay?: boolean; } export const AudioPlayer: React.FC = ({ src: initialSrc, onLoadRequest, className = "", autoPlay = false, }) => { const [isPlaying, setIsPlaying] = useState(false); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [isDragging, setIsDragging] = useState(false); const [loadedSrc, setLoadedSrc] = useState(initialSrc ?? null); const [isLoading, setIsLoading] = useState(false); const audioRef = useRef(null); const src = loadedSrc; const animationRef = useRef(); const dragTimeRef = useRef(0); // Use refs to avoid stale closures in animation loop const isPlayingRef = useRef(false); const isDraggingRef = useRef(false); // Keep refs in sync with state useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]); useEffect(() => { isDraggingRef.current = isDragging; }, [isDragging]); // Stable animation loop with no dependencies const tick = useCallback(() => { if (audioRef.current && !isDraggingRef.current) { const time = audioRef.current.currentTime; setCurrentTime(time); } if (isPlayingRef.current) { animationRef.current = requestAnimationFrame(tick); } }, []); // Empty dependency array is key! // Manage animation loop lifecycle useEffect(() => { if (isPlaying && !isDragging) { // Only start if not already running if (!animationRef.current) { animationRef.current = requestAnimationFrame(tick); } } else { // Stop animation loop if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = undefined; } } return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = undefined; } }; }, [isPlaying, isDragging, tick]); // Audio event handlers useEffect(() => { const audio = audioRef.current; if (!audio) return; const handleLoadedMetadata = () => { setDuration(audio.duration || 0); setCurrentTime(0); }; const handleEnded = () => { setIsPlaying(false); setCurrentTime(audio.duration || 0); }; const handlePlay = () => setIsPlaying(true); const handlePause = () => setIsPlaying(false); audio.addEventListener("loadedmetadata", handleLoadedMetadata); audio.addEventListener("ended", handleEnded); audio.addEventListener("play", handlePlay); audio.addEventListener("pause", handlePause); return () => { audio.removeEventListener("loadedmetadata", handleLoadedMetadata); audio.removeEventListener("ended", handleEnded); audio.removeEventListener("play", handlePlay); audio.removeEventListener("pause", handlePause); }; }, []); // Auto-play when src becomes available (via onLoadRequest or autoPlay prop) const prevLoadedSrc = useRef(null); useEffect(() => { const audio = audioRef.current; if (!audio) return; // Play when loadedSrc changes from null to a value (lazy load case) if (loadedSrc && !prevLoadedSrc.current && onLoadRequest) { audio.play().catch((error) => { console.error("Auto-play failed:", error); }); } // Or when autoPlay is set with initial src else if (autoPlay && initialSrc && !prevLoadedSrc.current) { audio.play().catch((error) => { console.error("Auto-play failed:", error); }); } prevLoadedSrc.current = loadedSrc; }, [loadedSrc, autoPlay, initialSrc, onLoadRequest]); // Global drag handlers const handleMouseUp = useCallback(() => { if (isDragging) { setIsDragging(false); if (audioRef.current) { audioRef.current.currentTime = dragTimeRef.current; setCurrentTime(dragTimeRef.current); } } }, [isDragging]); useEffect(() => { if (isDragging) { document.addEventListener("mouseup", handleMouseUp); document.addEventListener("touchend", handleMouseUp); return () => { document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("touchend", handleMouseUp); }; } }, [isDragging, handleMouseUp]); // Cleanup blob URLs on unmount useEffect(() => { return () => { if (loadedSrc?.startsWith("blob:")) { URL.revokeObjectURL(loadedSrc); } }; }, [loadedSrc]); const togglePlay = async () => { const audio = audioRef.current; if (!audio) return; if (isLoading) return; try { if (isPlaying) { audio.pause(); } else { // If no src loaded yet, request it if (!src && onLoadRequest) { setIsLoading(true); const newSrc = await onLoadRequest(); setIsLoading(false); if (newSrc) { setLoadedSrc(newSrc); // Playback will be triggered by the useEffect watching loadedSrc } } else if (src) { await audio.play(); } } } catch (error) { console.error("Playback failed:", error); } }; const handleSeek = (e: React.ChangeEvent) => { const newTime = parseFloat(e.target.value); dragTimeRef.current = newTime; setCurrentTime(newTime); if (!isDragging && audioRef.current) { audioRef.current.currentTime = newTime; } }; const handleSliderMouseDown = () => { setIsDragging(true); }; const handleSliderTouchStart = () => { setIsDragging(true); }; const formatTime = (time: number): string => { if (!isFinite(time)) return "0:00"; const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; // Fix playhead positioning with better edge case handling const getProgressPercent = (): number => { if (duration <= 0) return 0; // Handle the end case - if we're within 0.1 seconds of the end, show 100% if (duration - currentTime < 0.1) return 100; const percent = (currentTime / duration) * 100; return Math.min(100, Math.max(0, percent)); }; const progressPercent = getProgressPercent(); return (
); };