murmuraba
Version:
Real-time audio noise reduction with advanced chunked processing for web applications
191 lines (190 loc) • 9.78 kB
JavaScript
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)] })] })] })] }));
}