Files
audio-ui/components/editor/PlaybackControls.tsx
Sebastian Krüger ebfb4d3fff feat: implement Phase 2 - Web Audio API engine and waveform visualization
Phase 2 Complete Features:
- Web Audio API context management with browser compatibility
- Audio file upload with drag-and-drop support
- Audio decoding for multiple formats (WAV, MP3, OGG, FLAC, AAC, M4A)
- AudioPlayer class with full playback control
- Waveform visualization using Canvas API
- Real-time waveform rendering with progress indicator
- Playback controls (play, pause, stop, seek)
- Volume control with mute/unmute
- Timeline scrubbing
- Audio file information display

Components:
- AudioEditor: Main editor container
- FileUpload: Drag-and-drop file upload component
- AudioInfo: Display audio file metadata
- Waveform: Canvas-based waveform visualization
- PlaybackControls: Transport controls with volume slider

Audio Engine:
- lib/audio/context.ts: AudioContext management
- lib/audio/decoder.ts: Audio file decoding utilities
- lib/audio/player.ts: AudioPlayer class for playback
- lib/waveform/peaks.ts: Waveform peak generation

Hooks:
- useAudioPlayer: Complete audio player state management

Types:
- types/audio.ts: TypeScript definitions for audio types

Features Working:
✓ Load audio files via drag-and-drop or file picker
✓ Display waveform with real-time progress
✓ Play/pause/stop controls
✓ Seek by clicking on waveform or using timeline slider
✓ Volume control with visual feedback
✓ Audio file metadata display (duration, sample rate, channels)
✓ Toast notifications for user feedback
✓ SSR-safe audio context initialization
✓ Dark/light theme support

Tech Stack:
- Web Audio API for playback
- Canvas API for waveform rendering
- React 19 hooks for state management
- TypeScript for type safety

Build verified and working ✓

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:32:00 +01:00

175 lines
4.9 KiB
TypeScript

'use client';
import * as React from 'react';
import { Play, Pause, Square, SkipBack, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
export interface PlaybackControlsProps {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
volume: number;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
disabled?: boolean;
className?: string;
currentTimeFormatted?: string;
durationFormatted?: string;
}
export function PlaybackControls({
isPlaying,
isPaused,
currentTime,
duration,
volume,
onPlay,
onPause,
onStop,
onSeek,
onVolumeChange,
disabled = false,
className,
currentTimeFormatted,
durationFormatted,
}: PlaybackControlsProps) {
const [isMuted, setIsMuted] = React.useState(false);
const [previousVolume, setPreviousVolume] = React.useState(volume);
const handlePlayPause = () => {
if (isPlaying) {
onPause();
} else {
onPlay();
}
};
const handleMuteToggle = () => {
if (isMuted) {
onVolumeChange(previousVolume);
setIsMuted(false);
} else {
setPreviousVolume(volume);
onVolumeChange(0);
setIsMuted(true);
}
};
const handleVolumeChange = (newVolume: number) => {
onVolumeChange(newVolume);
if (newVolume === 0) {
setIsMuted(true);
} else {
setIsMuted(false);
}
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={cn('space-y-4', className)}>
{/* Timeline Slider */}
<div className="space-y-2">
<input
type="range"
min={0}
max={duration || 100}
step={0.01}
value={currentTime}
onChange={(e) => onSeek(parseFloat(e.target.value))}
disabled={disabled || duration === 0}
className={cn(
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-colors',
'[&::-webkit-slider-thumb]:hover:bg-primary/90',
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer'
)}
style={{
background: `linear-gradient(to right, var(--color-primary) ${progress}%, var(--color-secondary) ${progress}%)`,
}}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentTimeFormatted || '00:00'}</span>
<span>{durationFormatted || '00:00'}</span>
</div>
</div>
{/* Transport Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="default"
size="icon"
onClick={handlePlayPause}
disabled={disabled}
title={isPlaying ? 'Pause' : 'Play'}
className="h-12 w-12"
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-0.5" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<Square className="h-4 w-4" />
</Button>
</div>
{/* Volume Control */}
<div className="flex items-center gap-3 min-w-[200px]">
<Button
variant="ghost"
size="icon"
onClick={handleMuteToggle}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<Slider
value={volume}
onChange={handleVolumeChange}
min={0}
max={1}
step={0.01}
className="flex-1"
/>
</div>
</div>
</div>
);
}