fix: proper seeking behavior with optional auto-play

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-17 17:30:11 +01:00
parent 9aac873b53
commit 10d2921147
4 changed files with 44 additions and 18 deletions

View File

@@ -15,7 +15,7 @@ export interface PlaybackControlsProps {
onPlay: () => void; onPlay: () => void;
onPause: () => void; onPause: () => void;
onStop: () => void; onStop: () => void;
onSeek: (time: number) => void; onSeek: (time: number, autoPlay?: boolean) => void;
onVolumeChange: (volume: number) => void; onVolumeChange: (volume: number) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@@ -82,7 +82,9 @@ export function PlaybackControls({
max={duration || 100} max={duration || 100}
step={0.01} step={0.01}
value={currentTime} 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} disabled={disabled || duration === 0}
className={cn( className={cn(
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer', 'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',

View File

@@ -9,7 +9,7 @@ export interface WaveformProps {
audioBuffer: AudioBuffer | null; audioBuffer: AudioBuffer | null;
currentTime: number; currentTime: number;
duration: number; duration: number;
onSeek?: (time: number) => void; onSeek?: (time: number, autoPlay?: boolean) => void;
className?: string; className?: string;
height?: number; height?: number;
zoom?: number; zoom?: number;
@@ -213,9 +213,9 @@ export function Waveform({
setSelectionStart(clickedTime); setSelectionStart(clickedTime);
onSelectionChange({ start: clickedTime, end: clickedTime }); onSelectionChange({ start: clickedTime, end: clickedTime });
} else if (onSeek) { } else if (onSeek) {
// Regular dragging for scrubbing // Regular dragging for scrubbing (without auto-play)
setIsDragging(true); setIsDragging(true);
onSeek(clickedTime); onSeek(clickedTime, false);
} }
}; };
@@ -238,13 +238,28 @@ export function Waveform({
const end = Math.max(selectionStart, clampedTime); const end = Math.max(selectionStart, clampedTime);
onSelectionChange({ start, end }); onSelectionChange({ start, end });
} }
// Handle scrubbing // Handle scrubbing (without auto-play during drag)
else if (isDragging && onSeek) { else if (isDragging && onSeek) {
onSeek(clampedTime); onSeek(clampedTime, false);
} }
}; };
const handleMouseUp = () => { const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
// 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); setIsDragging(false);
setIsSelecting(false); setIsSelecting(false);
setSelectionStart(null); setSelectionStart(null);

View File

@@ -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<void> { async seek(time: number, autoPlay: boolean = false): Promise<void> {
if (!this.audioBuffer) return; if (!this.audioBuffer) return;
const wasPlaying = this.isPlaying;
const clampedTime = Math.max(0, Math.min(time, this.audioBuffer.duration)); const clampedTime = Math.max(0, Math.min(time, this.audioBuffer.duration));
this.stop(); this.stop();
this.pauseTime = clampedTime; this.pauseTime = clampedTime;
// Always start playback after seeking // Auto-play if requested, or continue playing if was already playing
await this.play(clampedTime); if (autoPlay || wasPlaying) {
await this.play(clampedTime);
} else {
this.isPaused = true;
}
} }
/** /**

View File

@@ -14,7 +14,7 @@ export interface UseAudioPlayerReturn {
play: () => Promise<void>; play: () => Promise<void>;
pause: () => void; pause: () => void;
stop: () => void; stop: () => void;
seek: (time: number) => Promise<void>; seek: (time: number, autoPlay?: boolean) => Promise<void>;
// Volume control // Volume control
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
@@ -159,14 +159,16 @@ export function useAudioPlayer(): UseAudioPlayerReturn {
}, [player]); }, [player]);
const seek = React.useCallback( const seek = React.useCallback(
async (time: number) => { async (time: number, autoPlay: boolean = false) => {
if (!player) return; if (!player) return;
await player.seek(time); await player.seek(time, autoPlay);
setCurrentTime(time); setCurrentTime(time);
// Seek now auto-starts playback, so update state accordingly
setIsPlaying(true); // Update state based on what actually happened
setIsPaused(false); const state = player.getState();
setIsPlaying(state.isPlaying);
setIsPaused(state.isPaused);
}, },
[player] [player]
); );