From 10d29211471edf0e7fe4d75b24381c4eb6aa14f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Nov 2025 17:30:11 +0100 Subject: [PATCH] fix: proper seeking behavior with optional auto-play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of seeking logic to support both scrubbing and click-to-play functionality with proper state management. Changes: 1. Added autoPlay parameter to seek() methods across the stack 2. Waveform behavior: - Click and drag → scrubs WITHOUT auto-play during drag - Mouse up after drag → auto-plays from release position - This allows smooth scrubbing while dragging 3. Timeline slider behavior: - onChange → seeks WITHOUT auto-play (smooth dragging) - onMouseUp/onTouchEnd → auto-plays from final position 4. Transport button state now correctly syncs with playback state Technical implementation: - player.seek(time, autoPlay) - autoPlay defaults to false - If autoPlay=true OR was already playing → continues playback - If autoPlay=false AND wasn't playing → just seeks (isPaused=true) - useAudioPlayer.seek() now reads actual player state after seeking User experience: ✓ Click on waveform → music plays from that position ✓ Drag on waveform → scrubs smoothly, plays on release ✓ Drag timeline slider → scrubs smoothly, plays on release ✓ Transport buttons show correct state (Play/Pause) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/editor/PlaybackControls.tsx | 6 ++++-- components/editor/Waveform.tsx | 27 ++++++++++++++++++++------ lib/audio/player.ts | 15 ++++++++++---- lib/hooks/useAudioPlayer.ts | 14 +++++++------ 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/components/editor/PlaybackControls.tsx b/components/editor/PlaybackControls.tsx index 7981bc8..aff9da8 100644 --- a/components/editor/PlaybackControls.tsx +++ b/components/editor/PlaybackControls.tsx @@ -15,7 +15,7 @@ export interface PlaybackControlsProps { onPlay: () => void; onPause: () => void; onStop: () => void; - onSeek: (time: number) => void; + onSeek: (time: number, autoPlay?: boolean) => void; onVolumeChange: (volume: number) => void; disabled?: boolean; className?: string; @@ -82,7 +82,9 @@ export function PlaybackControls({ max={duration || 100} step={0.01} value={currentTime} - onChange={(e) => onSeek(parseFloat(e.target.value))} + onChange={(e) => onSeek(parseFloat(e.target.value), false)} + onMouseUp={(e) => onSeek(parseFloat((e.target as HTMLInputElement).value), true)} + onTouchEnd={(e) => onSeek(parseFloat((e.target as HTMLInputElement).value), true)} disabled={disabled || duration === 0} className={cn( 'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer', diff --git a/components/editor/Waveform.tsx b/components/editor/Waveform.tsx index 20b5c38..04152f4 100644 --- a/components/editor/Waveform.tsx +++ b/components/editor/Waveform.tsx @@ -9,7 +9,7 @@ export interface WaveformProps { audioBuffer: AudioBuffer | null; currentTime: number; duration: number; - onSeek?: (time: number) => void; + onSeek?: (time: number, autoPlay?: boolean) => void; className?: string; height?: number; zoom?: number; @@ -213,9 +213,9 @@ export function Waveform({ setSelectionStart(clickedTime); onSelectionChange({ start: clickedTime, end: clickedTime }); } else if (onSeek) { - // Regular dragging for scrubbing + // Regular dragging for scrubbing (without auto-play) setIsDragging(true); - onSeek(clickedTime); + onSeek(clickedTime, false); } }; @@ -238,13 +238,28 @@ export function Waveform({ const end = Math.max(selectionStart, clampedTime); onSelectionChange({ start, end }); } - // Handle scrubbing + // Handle scrubbing (without auto-play during drag) else if (isDragging && onSeek) { - onSeek(clampedTime); + onSeek(clampedTime, false); } }; - const handleMouseUp = () => { + const handleMouseUp = (e: React.MouseEvent) => { + // If we were dragging (scrubbing), trigger auto-play on mouse up + if (isDragging && onSeek && !isSelecting) { + const canvas = canvasRef.current; + if (canvas) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const visibleWidth = width * zoom; + const actualX = x + scrollOffset; + const releaseTime = (actualX / visibleWidth) * duration; + const clampedTime = Math.max(0, Math.min(duration, releaseTime)); + // Auto-play on mouse up after dragging + onSeek(clampedTime, true); + } + } + setIsDragging(false); setIsSelecting(false); setSelectionStart(null); diff --git a/lib/audio/player.ts b/lib/audio/player.ts index 4c669b6..92202ec 100644 --- a/lib/audio/player.ts +++ b/lib/audio/player.ts @@ -114,18 +114,25 @@ export class AudioPlayer { } /** - * Seek to a specific time and start playback + * Seek to a specific time + * @param time - Time in seconds to seek to + * @param autoPlay - Whether to automatically start playback after seeking (default: false) */ - async seek(time: number): Promise { + async seek(time: number, autoPlay: boolean = false): Promise { if (!this.audioBuffer) return; + const wasPlaying = this.isPlaying; const clampedTime = Math.max(0, Math.min(time, this.audioBuffer.duration)); this.stop(); this.pauseTime = clampedTime; - // Always start playback after seeking - await this.play(clampedTime); + // Auto-play if requested, or continue playing if was already playing + if (autoPlay || wasPlaying) { + await this.play(clampedTime); + } else { + this.isPaused = true; + } } /** diff --git a/lib/hooks/useAudioPlayer.ts b/lib/hooks/useAudioPlayer.ts index 7058a81..65129b5 100644 --- a/lib/hooks/useAudioPlayer.ts +++ b/lib/hooks/useAudioPlayer.ts @@ -14,7 +14,7 @@ export interface UseAudioPlayerReturn { play: () => Promise; pause: () => void; stop: () => void; - seek: (time: number) => Promise; + seek: (time: number, autoPlay?: boolean) => Promise; // Volume control setVolume: (volume: number) => void; @@ -159,14 +159,16 @@ export function useAudioPlayer(): UseAudioPlayerReturn { }, [player]); const seek = React.useCallback( - async (time: number) => { + async (time: number, autoPlay: boolean = false) => { if (!player) return; - await player.seek(time); + await player.seek(time, autoPlay); setCurrentTime(time); - // Seek now auto-starts playback, so update state accordingly - setIsPlaying(true); - setIsPaused(false); + + // Update state based on what actually happened + const state = player.getState(); + setIsPlaying(state.isPlaying); + setIsPaused(state.isPaused); }, [player] );