Merge pull request #293 from jamiepine/fix/audio-player-freeze

Fix audio player freezing and improve UX
This commit is contained in:
Jamie Pine
2026-03-16 22:28:39 -07:00
committed by GitHub
3 changed files with 195 additions and 414 deletions
+189 -406
View File
@@ -17,7 +17,6 @@ export function AudioPlayer() {
audioUrl, audioUrl,
audioId, audioId,
profileId, profileId,
title,
isPlaying, isPlaying,
currentTime, currentTime,
duration, duration,
@@ -63,7 +62,7 @@ export function AudioPlayer() {
); );
return shouldUseNative; return shouldUseNative;
}, [profileChannels, channels, profileId]); }, [profileChannels, channels, platform.metadata.isTauri]);
const waveformRef = useRef<HTMLDivElement>(null); const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null); const wavesurferRef = useRef<WaveSurfer | null>(null);
@@ -73,31 +72,21 @@ export function AudioPlayer() {
const isUsingNativePlaybackRef = useRef(false); const isUsingNativePlaybackRef = useRef(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [wsReady, setWsReady] = useState(false);
// Initialize WaveSurfer (only when audioUrl exists and container is ready) // Create WaveSurfer once when the player becomes visible (audioUrl is set).
// This instance is reused for all subsequent audio loads - never destroyed until unmount.
useEffect(() => { useEffect(() => {
// Don't initialize if no audioUrl or already initialized if (!audioUrl) return;
if (!audioUrl) { if (wavesurferRef.current) return; // already created
return;
}
if (wavesurferRef.current) {
debug.log('WaveSurfer already initialized, skipping');
return;
}
debug.log('Creating NEW WaveSurfer instance');
// Wait for container to be properly rendered
const initWaveSurfer = () => { const initWaveSurfer = () => {
const container = waveformRef.current; const container = waveformRef.current;
if (!container) { if (!container) {
// Container not ready yet, retry
setTimeout(initWaveSurfer, 50); setTimeout(initWaveSurfer, 50);
return; return;
} }
// Check if container has dimensions and is visible
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container); const style = window.getComputedStyle(container);
const isVisible = const isVisible =
@@ -107,412 +96,221 @@ export function AudioPlayer() {
style.visibility !== 'hidden'; style.visibility !== 'hidden';
if (!isVisible) { if (!isVisible) {
// Retry after a short delay
setTimeout(initWaveSurfer, 50); setTimeout(initWaveSurfer, 50);
return; return;
} }
debug.log('Initializing WaveSurfer...', { debug.log('Creating WaveSurfer instance', {
container,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
}); });
try { try {
// Get computed CSS variable values
const root = document.documentElement; const root = document.documentElement;
const getCSSVar = (varName: string) => { const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim(); const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : ''; return value ? `hsl(${value})` : '';
}; };
const waveColor = getCSSVar('--muted');
const progressColor = getCSSVar('--accent');
const cursorColor = getCSSVar('--accent');
const wavesurfer = WaveSurfer.create({ const wavesurfer = WaveSurfer.create({
container: container, container,
waveColor: waveColor, waveColor: getCSSVar('--muted'),
progressColor: progressColor, progressColor: getCSSVar('--accent'),
cursorColor: cursorColor, cursorColor: getCSSVar('--accent'),
cursorWidth: 3,
barWidth: 2, barWidth: 2,
barRadius: 2, barRadius: 2,
height: 80, height: 80,
normalize: true, normalize: true,
// Use MediaElement backend (default). Unlike the WebAudio backend, interact: true,
// MediaElement uses a standard <audio> element for playback which dragToSeek: { debounceTime: 0 },
// benefits from the browser/webview's built-in audio session recovery. mediaControls: false,
// This prevents audio loss when another app steals audio output or backend: 'WebAudio',
// the system audio session is interrupted.
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
}); });
wavesurferRef.current = wavesurfer; // Wire up event handlers (these persist for the lifetime of the instance)
debug.log('WaveSurfer created successfully'); wavesurfer.on('timeupdate', (time) => {
} catch (error) { const dur = usePlayerStore.getState().duration;
debug.error('Failed to create WaveSurfer:', error); if (dur > 0 && time >= dur) {
setError( setCurrentTime(dur);
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`, const loop = usePlayerStore.getState().isLooping;
); if (loop) {
return; wavesurfer.seekTo(0);
} wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
setCurrentTime(time);
});
const wavesurfer = wavesurferRef.current; wavesurfer.on('ready', () => {
if (!wavesurfer) return; const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
debug.log('Audio ready, duration:', dur);
// Update store when time changes, stop if past duration wavesurfer.setVolume(usePlayerStore.getState().volume);
wavesurfer.on('timeupdate', (time) => { wavesurfer.setMuted(false);
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) { // Auto-play if the flag is set (story mode advance or explicit play)
setCurrentTime(dur); const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
if (shouldAutoPlayNow) {
usePlayerStore.getState().clearAutoPlayFlag();
wavesurfer.play().catch((err) => {
debug.error('Failed to autoplay:', err);
});
} else {
debug.log('Skipping auto-play - shouldAutoPlay is false');
}
});
wavesurfer.on('play', () => setIsPlaying(true));
wavesurfer.on('pause', () => {
setIsPlaying(false);
setCurrentTime(wavesurfer.getCurrentTime());
});
wavesurfer.on('seeking', (time) => setCurrentTime(time));
// Mute audio during drag-to-seek to prevent popping from the WebAudio
// backend's hard stop/start cycle on each seek. Unmute with a short
// fade-in when the drag ends.
const seekMedia = wavesurfer.getMediaElement() as any;
const seekGain: GainNode | null = seekMedia?.getGainNode?.() ?? null;
if (seekGain) {
const ctx = seekGain.context as AudioContext;
wavesurfer.on('dragstart', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(0, ctx.currentTime, 0.002);
});
wavesurfer.on('dragend', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(1, ctx.currentTime, 0.01);
});
}
wavesurfer.on('finish', () => {
const loop = usePlayerStore.getState().isLooping; const loop = usePlayerStore.getState().isLooping;
if (loop) { if (loop) {
wavesurfer.seekTo(0); wavesurfer.seekTo(0);
wavesurfer.play(); wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else { } else {
wavesurfer.pause();
setIsPlaying(false); setIsPlaying(false);
const onFinish = usePlayerStore.getState().onFinish;
if (onFinish) onFinish();
} }
return;
}
setCurrentTime(time);
});
// Update store when duration is loaded
wavesurfer.on('ready', async () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
debug.log('Audio ready, duration:', dur);
debug.log('Waveform should be visible now');
// Ensure volume is set
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Auto-play when ready - check if we should use native playback
// Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl;
const currentProfileId = usePlayerStore.getState().profileId;
debug.log('Auto-play check - capturing runtime values...');
// Fetch profile channels at runtime (not using captured value)
let runtimeProfileChannels = null;
let runtimeChannels = null;
if (platform.metadata.isTauri && currentProfileId) {
try {
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
debug.log('Runtime profileChannels:', runtimeProfileChannels);
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
runtimeChannels = await apiClient.listChannels();
debug.log('Runtime channels:', runtimeChannels);
}
} catch (error) {
debug.error('Failed to fetch runtime channel data:', error);
}
}
debug.log('Auto-play check:', {
isTauri: platform.metadata.isTauri,
currentAudioUrl,
currentProfileId,
hasProfileChannels: !!runtimeProfileChannels,
hasChannels: !!runtimeChannels,
}); });
if ( wavesurfer.on('error', (err) => {
platform.metadata.isTauri && debug.error('WaveSurfer error:', err);
currentAudioUrl &&
currentProfileId &&
runtimeProfileChannels &&
runtimeChannels
) {
debug.log('Attempting native audio playback...');
// Stop any existing native playback first
if (isUsingNativePlaybackRef.current) {
try {
platform.audio.stopPlayback();
debug.log('Stopped existing native playback before starting new one');
} catch (error) {
debug.error('Failed to stop existing playback:', error);
}
}
try {
// Collect all device IDs from assigned channels
const assignedChannels = runtimeChannels.filter((ch: any) =>
runtimeProfileChannels.channel_ids.includes(ch.id),
);
debug.log('Assigned channels for playback:', assignedChannels);
// Check if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some(
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
);
debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) {
debug.log('No custom devices assigned, using standard playback');
isUsingNativePlaybackRef.current = false;
} else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
debug.log('Device IDs to play to:', deviceIds);
if (deviceIds.length > 0) {
debug.log('Fetching audio data from:', currentAudioUrl);
// Fetch audio data
const response = await fetch(currentAudioUrl);
const audioData = new Uint8Array(await response.arrayBuffer());
debug.log('Audio data size:', audioData.length);
// Play via native audio
debug.log('Invoking play_audio_to_devices...');
try {
await platform.audio.playToDevices(audioData, deviceIds);
debug.log('play_audio_to_devices completed successfully');
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer's audio output — native handles the actual sound
// Keep WaveSurfer running for waveform visualization
wavesurfer.setVolume(0);
wavesurfer.setMuted(true);
// Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => {
debug.error('Failed to start WaveSurfer visualization:', error);
});
setIsPlaying(true);
debug.log('Auto-playing via native audio routing - SUCCESS');
return;
} catch (invokeError) {
debug.error('play_audio_to_devices invoke failed:', invokeError);
throw invokeError;
}
} else {
debug.log('No device IDs found, falling back to WaveSurfer');
}
}
} catch (error) {
debug.error(
'Native playback failed during auto-play, falling back to WaveSurfer:',
error,
);
isUsingNativePlaybackRef.current = false;
// Fall through to WaveSurfer playback
}
}
// Standard playback path — ensure WaveSurfer is unmuted
if (!isUsingNativePlaybackRef.current) {
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
if (shouldAutoPlayNow) {
// Clear the flag first
usePlayerStore.getState().clearAutoPlayFlag();
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
debug.error('Failed to autoplay:', error);
// Don't show error for autoplay failures (browser restrictions)
});
}, 100);
} else {
debug.log('Skipping auto-play - shouldAutoPlay is false');
}
});
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
// Check loop state from store
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
setIsPlaying(false);
// Trigger finish callback if set
const onFinish = usePlayerStore.getState().onFinish;
if (onFinish) {
onFinish();
}
}
});
// Handle errors
wavesurfer.on('error', (error) => {
debug.error('WaveSurfer error:', error);
setIsLoading(false);
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
});
// Handle loading
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) {
setIsLoading(false); setIsLoading(false);
} setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
}); });
// Load audio immediately if audioUrl is already set wavesurfer.on('loading', (percent) => {
if (audioUrl) { setIsLoading(true);
debug.log('WaveSurfer ready, loading audio:', audioUrl); if (percent === 100) setIsLoading(false);
loadingRef.current = true; });
setIsLoading(true);
// Stop any current playback before loading new audio wavesurferRef.current = wavesurfer;
if (wavesurfer.isPlaying()) { setWsReady(true);
wavesurfer.pause(); debug.log('WaveSurfer created successfully');
} } catch (err) {
wavesurfer debug.error('Failed to create WaveSurfer:', err);
.load(audioUrl) setError(
.then(() => { `Failed to initialize waveform: ${err instanceof Error ? err.message : String(err)}`,
debug.log('Audio loaded into WaveSurfer'); );
loadingRef.current = false;
})
.catch((error) => {
debug.error('Failed to load audio into WaveSurfer:', error);
loadingRef.current = false;
setIsLoading(false);
setError(
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
);
});
} }
}; };
// Use double requestAnimationFrame to ensure DOM is fully rendered let rafId: number;
let rafId1: number; rafId = requestAnimationFrame(() => {
let rafId2: number; initWaveSurfer();
let timeoutId: number | null = null;
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// Add a small delay to ensure container is fully laid out
timeoutId = setTimeout(() => {
initWaveSurfer();
}, 10);
});
}); });
return () => { return () => {
debug.log('Cleaning up WaveSurfer initialization effect'); cancelAnimationFrame(rafId);
if (rafId1) cancelAnimationFrame(rafId1); };
if (rafId2) cancelAnimationFrame(rafId2); // Only run on mount-like conditions. audioUrl is here so we create the instance
if (timeoutId) clearTimeout(timeoutId); // when the player first appears, but we guard against re-creation above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioUrl, setIsPlaying, setDuration, setCurrentTime]);
// Destroy WaveSurfer only on unmount
useEffect(() => {
return () => {
if (wavesurferRef.current) { if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance'); debug.log('Destroying WaveSurfer instance (unmount)');
try { try {
wavesurferRef.current.destroy(); wavesurferRef.current.destroy();
} catch (error) { } catch (err) {
debug.error('Error destroying WaveSurfer:', error); debug.error('Error destroying WaveSurfer:', err);
} }
wavesurferRef.current = null; wavesurferRef.current = null;
setWsReady(false);
} }
}; };
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]); }, []);
// Load audio when URL changes (only if WaveSurfer is already initialized) // Load audio when URL changes (reuses the existing WaveSurfer instance)
useEffect(() => { useEffect(() => {
const wavesurfer = wavesurferRef.current; const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !wsReady) return;
if (!audioUrl || !wavesurfer) { if (!audioUrl) {
// Reset state when no audio or WaveSurfer not ready // No audio - pause and reset
if (!audioUrl && wavesurfer) { wavesurfer.pause();
wavesurfer.pause(); wavesurfer.seekTo(0);
wavesurfer.seekTo(0); loadingRef.current = false;
loadingRef.current = false; setIsLoading(false);
setIsLoading(false); setDuration(0);
setDuration(0); setCurrentTime(0);
setCurrentTime(0); setError(null);
setError(null); isUsingNativePlaybackRef.current = false;
// Reset native playback flag
isUsingNativePlaybackRef.current = false;
}
return; return;
} }
// Stop native playback if it was active // Reset native playback state
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
try {
platform.audio.stopPlayback();
debug.log('Stopped native audio playback');
} catch (error) {
debug.error('Failed to stop native playback:', error);
}
}
// Reset native playback flag when loading new audio
// Unmute WaveSurfer if it was muted for native playback
if (isUsingNativePlaybackRef.current) {
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
isUsingNativePlaybackRef.current = false; isUsingNativePlaybackRef.current = false;
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
// CRITICAL: Force stop any current playback and cancel any pending loads // Stop current playback and reset position before loading new audio.
// This must happen BEFORE any early returns // With the WebAudio backend, pause() accumulates playedDuration internally.
debug.log('Audio URL changed to:', audioUrl); // seekTo(0) resets it so the new track starts from the beginning.
debug.log('Loading new audio URL:', audioUrl);
// COMPLETELY stop and destroy the current audio
try { try {
// First pause if playing
if (wavesurfer.isPlaying()) { if (wavesurfer.isPlaying()) {
debug.log('Pausing current playback');
wavesurfer.pause(); wavesurfer.pause();
} }
wavesurfer.seekTo(0);
// Use empty() to completely destroy the waveform and reset media } catch (err) {
debug.log('Calling wavesurfer.empty() to destroy audio'); debug.error('Error resetting before load:', err);
wavesurfer.empty();
} catch (error) {
debug.error('Error stopping previous audio:', error);
// Continue anyway to load new audio
} }
// Reset loading state to allow new load (cancel any pending loads)
loadingRef.current = false;
// Now start the new load
loadingRef.current = true; loadingRef.current = true;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
// Load new audio
debug.log('Starting new audio load for:', audioUrl);
wavesurfer wavesurfer
.load(audioUrl) .load(audioUrl)
.then(() => { .then(() => {
debug.log('Audio load promise resolved'); debug.log('Audio loaded into WaveSurfer');
// Don't set loading to false here - wait for 'ready' event loadingRef.current = false;
}) })
.catch((error) => { .catch((err) => {
debug.error('Failed to load audio:', error); debug.error('Failed to load audio:', err);
debug.error('Audio URL:', audioUrl);
loadingRef.current = false; loadingRef.current = false;
setIsLoading(false); setIsLoading(false);
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`); setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
}); });
}, [audioUrl, setCurrentTime, setDuration]); }, [audioUrl, wsReady, setCurrentTime, setDuration]);
// Sync play/pause state (only when user clicks play/pause button, not auto-sync) // Sync play/pause state (only when user clicks play/pause button, not auto-sync)
// This effect is kept for external state changes but should be minimal // This effect is kept for external state changes but should be minimal
@@ -520,7 +318,6 @@ export function AudioPlayer() {
if (!wavesurferRef.current || duration === 0) return; if (!wavesurferRef.current || duration === 0) return;
if (isPlaying && wavesurferRef.current.isPlaying() === false) { if (isPlaying && wavesurferRef.current.isPlaying() === false) {
// Only auto-play if audio is ready
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
debug.error('Failed to play:', error); debug.error('Failed to play:', error);
setIsPlaying(false); setIsPlaying(false);
@@ -534,14 +331,7 @@ export function AudioPlayer() {
// Sync volume // Sync volume
useEffect(() => { useEffect(() => {
if (wavesurferRef.current) { if (wavesurferRef.current) {
// If using native playback, keep WaveSurfer muted regardless of volume setting wavesurferRef.current.setVolume(volume);
if (isUsingNativePlaybackRef.current) {
wavesurferRef.current.setVolume(0);
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else {
wavesurferRef.current.setVolume(volume);
debug.log('Volume synced:', volume);
}
} }
}, [volume]); }, [volume]);
@@ -566,7 +356,6 @@ export function AudioPlayer() {
return; return;
} }
// Reset to beginning and play
debug.log('Restarting current audio from beginning'); debug.log('Restarting current audio from beginning');
wavesurfer.seekTo(0); wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
@@ -575,34 +364,35 @@ export function AudioPlayer() {
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
}); });
// Clear the restart flag
clearRestartFlag(); clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]); }, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Handle shouldAutoPlay flag - for story mode auto-advance // Auto-play is handled exclusively in the WaveSurfer 'ready' event handler.
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay); // A separate effect here would race with the ready event since the WebAudio
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag); // backend needs to fully decode the audio before play() works correctly.
// Spacebar to play/pause (capture phase so it fires before focused elements)
useEffect(() => { useEffect(() => {
const wavesurfer = wavesurferRef.current; const onKeyDown = (e: KeyboardEvent) => {
if (!wavesurfer || !shouldAutoPlay || duration === 0) { if (e.code !== 'Space') return;
return; // Ignore if user is typing in an input/textarea
} const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
// Auto-play the newly loaded audio return;
debug.log('Auto-playing next track in story mode'); }
wavesurfer.seekTo(0); if (audioUrl && duration > 0 && wavesurferRef.current) {
wavesurfer.play().catch((error) => { e.preventDefault();
debug.error('Failed to auto-play:', error); e.stopPropagation();
setIsPlaying(false); if (wavesurferRef.current.isPlaying()) {
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); wavesurferRef.current.pause();
}); } else {
wavesurferRef.current.play().catch((err) => debug.error('Spacebar play failed:', err));
// Clear the auto-play flag }
clearAutoPlayFlag(); }
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]); };
document.addEventListener('keydown', onKeyDown, true);
// Handle loop - WaveSurfer handles this via the 'finish' event return () => document.removeEventListener('keydown', onKeyDown, true);
}, [audioUrl, duration]);
const handlePlayPause = async () => { const handlePlayPause = async () => {
// Standard WaveSurfer playback (works for both normal and native playback modes) // Standard WaveSurfer playback (works for both normal and native playback modes)
@@ -741,32 +531,32 @@ export function AudioPlayer() {
size="icon" size="icon"
onClick={handlePlayPause} onClick={handlePlayPause}
disabled={isLoading || duration === 0} disabled={isLoading || duration === 0}
className="shrink-0" className={`shrink-0 -mt-2 ${isPlaying ? 'bg-accent text-accent-foreground' : ''}`}
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={ aria-label={
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play' duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
} }
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />} {isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button> </Button>
{/* Waveform */} {/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" /> <div ref={waveformRef} className="w-full min-h-[80px] select-none" />
{duration > 0 && ( <Slider
<Slider value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
value={duration > 0 ? [(currentTime / duration) * 100] : [0]} onValueChange={handleSeek}
onValueChange={handleSeek} max={100}
max={100} step={0.1}
step={0.1} className="w-full"
className="w-full" aria-label="Playback position"
aria-label="Playback position" aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`} />
/>
)}
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
)}
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>} {error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
</div> </div>
@@ -777,19 +567,12 @@ export function AudioPlayer() {
<span className="font-mono">{formatAudioDuration(duration)}</span> <span className="font-mono">{formatAudioDuration(duration)}</span>
</div> </div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
{title}
</div>
)}
{/* Loop Button */} {/* Loop Button */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleLoop} onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''} className={isLooping ? 'bg-accent text-accent-foreground' : ''}
title="Toggle loop" title="Toggle loop"
aria-label={isLooping ? 'Stop looping' : 'Loop'} aria-label={isLooping ? 'Stop looping' : 'Loop'}
> >
+2 -2
View File
@@ -64,7 +64,7 @@ function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
<div className="flex items-center gap-[2px] h-5"> <div className="flex items-center gap-[2px] h-5">
{[0, 1, 2, 3, 4].map((i) => ( {[0, 1, 2, 3, 4].map((i) => (
<motion.div <motion.div
key={i} key={`${mode}-${i}`}
className={`w-[3px] rounded-full ${barColor}`} className={`w-[3px] rounded-full ${barColor}`}
animate={ animate={
mode === 'generating' mode === 'generating'
@@ -440,7 +440,7 @@ export function HistoryTable() {
role={isPlayable ? 'button' : undefined} role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined} tabIndex={isPlayable ? 0 : undefined}
className={cn( className={cn(
'flex items-stretch gap-4 h-26 p-3', 'flex items-stretch gap-4 h-26 p-3 outline-none',
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md', isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
isVersionsExpanded && 'rounded-b-none', isVersionsExpanded && 'rounded-b-none',
)} )}
+2 -4
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
interface UseSystemAudioCaptureOptions { interface UseSystemAudioCaptureOptions {
@@ -100,9 +100,7 @@ export function useSystemAudioCapture({
onRecordingComplete?.(blob, recordedDuration); onRecordingComplete?.(blob, recordedDuration);
} catch (err) { } catch (err) {
const errorMessage = const errorMessage =
err instanceof Error err instanceof Error ? err.message : 'Failed to stop system audio capture.';
? err.message
: 'Failed to stop system audio capture.';
setError(errorMessage); setError(errorMessage);
} }
}, [isRecording, onRecordingComplete, platform]); }, [isRecording, onRecordingComplete, platform]);