UNPKG

murmuraba

Version:

Real-time audio noise reduction with advanced chunked processing for web applications

191 lines (190 loc) 9.78 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; import { formatDuration as formatDurationCore } from '../../utils/time-utils'; /** * Professional audio player component with comprehensive error handling, * accessibility support, and clean architecture. */ export function AudioPlayer({ src, onPlayStateChange, className = '', label, forceStop = false, 'aria-label': ariaLabel, disabled = false, volume = 1, muted = false, }) { const audioRef = useRef(null); const seekTimeoutRef = useRef(null); const mountedRef = useRef(true); const [state, setState] = useState({ isPlaying: false, currentTime: 0, duration: 0, isLoading: false, hasError: false, }); // Safe state updater that checks if component is still mounted const safeSetState = useCallback((updater) => { if (mountedRef.current) { setState(prev => ({ ...prev, ...updater })); } }, []); // Use unified time formatting utilities const formatTime = useCallback((timeInSeconds) => { // Convert seconds to milliseconds for the unified formatter const milliseconds = timeInSeconds * 1000; return formatDurationCore(milliseconds); }, []); // Calculate progress with safety checks const progress = useMemo(() => { if (state.duration <= 0 || !isFinite(state.duration)) return 0; if (state.currentTime < 0 || !isFinite(state.currentTime)) return 0; return Math.min(100, Math.max(0, (state.currentTime / state.duration) * 100)); }, [state.currentTime, state.duration]); // Audio event handlers with proper error boundaries const handleLoadStart = useCallback(() => { safeSetState({ isLoading: true, hasError: false }); }, [safeSetState]); const handleLoadedMetadata = useCallback(() => { const audio = audioRef.current; if (!audio) return; const duration = isFinite(audio.duration) ? audio.duration : 0; safeSetState({ duration, isLoading: false, hasError: false }); }, [safeSetState]); const handleTimeUpdate = useCallback(() => { const audio = audioRef.current; if (!audio || !mountedRef.current) return; safeSetState({ currentTime: audio.currentTime }); }, [safeSetState]); const handleEnded = useCallback(() => { safeSetState({ isPlaying: false, currentTime: 0 }); onPlayStateChange?.(false); }, [onPlayStateChange, safeSetState]); const handleError = useCallback((event) => { console.error('Audio playback error:', event); safeSetState({ isLoading: false, isPlaying: false, hasError: true }); onPlayStateChange?.(false); }, [onPlayStateChange, safeSetState]); // Setup audio event listeners with proper cleanup useEffect(() => { const audio = audioRef.current; if (!audio || !src) { safeSetState({ hasError: false, isLoading: false }); return; } // Event handlers map for clean management const eventHandlers = new Map([ ['loadstart', handleLoadStart], ['loadedmetadata', handleLoadedMetadata], ['timeupdate', handleTimeUpdate], ['ended', handleEnded], ['error', handleError], ]); // Add all event listeners eventHandlers.forEach((handler, event) => { audio.addEventListener(event, handler); }); // Cleanup function return () => { eventHandlers.forEach((handler, event) => { audio.removeEventListener(event, handler); }); }; }, [src, handleLoadStart, handleLoadedMetadata, handleTimeUpdate, handleEnded, handleError, safeSetState]); // Handle volume and muted state changes useEffect(() => { const audio = audioRef.current; if (audio) { audio.volume = Math.max(0, Math.min(1, volume)); audio.muted = muted; } }, [volume, muted]); // Handle forced stop useEffect(() => { if (forceStop && state.isPlaying && audioRef.current) { audioRef.current.pause(); safeSetState({ isPlaying: false }); onPlayStateChange?.(false); } }, [forceStop, state.isPlaying, onPlayStateChange, safeSetState]); // Cleanup on unmount useEffect(() => { return () => { mountedRef.current = false; if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); } }; }, []); // Toggle play/pause with comprehensive error handling const togglePlayPause = useCallback(async () => { const audio = audioRef.current; if (!audio || !src || disabled || state.isLoading) return; try { if (state.isPlaying) { audio.pause(); safeSetState({ isPlaying: false }); onPlayStateChange?.(false); } else { await audio.play(); safeSetState({ isPlaying: true, hasError: false }); onPlayStateChange?.(true); } } catch (error) { console.error('Playback failed:', error); safeSetState({ isPlaying: false, hasError: true }); onPlayStateChange?.(false); } }, [state.isPlaying, state.isLoading, src, disabled, onPlayStateChange, safeSetState]); // Handle seeking with debouncing for performance const handleSeek = useCallback((event) => { const audio = audioRef.current; if (!audio || state.duration <= 0 || disabled) return; const rect = event.currentTarget.getBoundingClientRect(); const clickX = event.clientX - rect.left; const percentage = Math.max(0, Math.min(1, clickX / rect.width)); const newTime = percentage * state.duration; // Clear existing timeout if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); } // Debounce seek operations seekTimeoutRef.current = setTimeout(() => { if (audio && mountedRef.current) { audio.currentTime = newTime; safeSetState({ currentTime: newTime }); } }, 50); }, [state.duration, disabled, safeSetState]); // Keyboard event handling for accessibility const handleKeyDown = useCallback((event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); togglePlayPause(); } }, [togglePlayPause]); // Render disabled state if (!src) { return (_jsxs("div", { className: `audio-player audio-player--disabled ${className}`.trim(), children: [_jsx("button", { className: "audio-player__button", disabled: true, "aria-label": ariaLabel || `${label} - No audio available`, children: _jsx("span", { className: "audio-player__icon", "aria-hidden": "true", children: "\u25B6\uFE0F" }) }), _jsxs("span", { className: "audio-player__label", children: [label, " - No audio"] })] })); } const isDisabled = disabled || state.isLoading; const effectiveAriaLabel = ariaLabel || `${label} - ${state.isPlaying ? 'Pause' : 'Play'} audio`; return (_jsxs("div", { className: `audio-player ${state.isPlaying ? 'audio-player--playing' : ''} ${state.hasError ? 'audio-player--error' : ''} ${className}`.trim(), role: "region", "aria-label": `Audio player for ${label}`, children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", "aria-hidden": "true" }), _jsx("button", { className: "audio-player__button", onClick: togglePlayPause, onKeyDown: handleKeyDown, disabled: isDisabled, "aria-label": effectiveAriaLabel, type: "button", children: state.isLoading ? (_jsx("span", { className: "audio-player__icon audio-player__icon--loading", "aria-hidden": "true", children: "\u23F3" })) : state.hasError ? (_jsx("span", { className: "audio-player__icon audio-player__icon--error", "aria-hidden": "true", children: "\u274C" })) : state.isPlaying ? (_jsx("span", { className: "audio-player__icon audio-player__icon--pause", "aria-hidden": "true", children: "\u23F8\uFE0F" })) : (_jsx("span", { className: "audio-player__icon audio-player__icon--play", "aria-hidden": "true", children: "\u25B6\uFE0F" })) }), _jsxs("div", { className: "audio-player__info", children: [_jsx("span", { className: "audio-player__label", children: label }), _jsxs("div", { className: "audio-player__progress-container", children: [_jsx("div", { className: "audio-player__progress-bar", onClick: handleSeek, role: "slider", "aria-valuemin": 0, "aria-valuemax": state.duration, "aria-valuenow": state.currentTime, "aria-label": "Seek position", tabIndex: disabled ? -1 : 0, onKeyDown: (e) => { // TODO: Add arrow key support for seeking if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { // Implement arrow key seeking } }, children: _jsx("div", { className: "audio-player__progress-fill", style: { width: `${progress}%` }, "aria-hidden": "true" }) }), _jsxs("span", { className: "audio-player__time", "aria-label": `Current time: ${formatTime(state.currentTime)}, Duration: ${formatTime(state.duration)}`, children: [formatTime(state.currentTime), " / ", formatTime(state.duration)] })] })] })] })); }