Nicholai d3158e4c6a feat(timeline-mixer): WIP timeline and mixer components
work in progress implementation of:
- mixer with channel strips, faders, pan knobs, level meters
- timeline with ruler, playhead, sections, keyframe tracks
- pattern and progression pickers for drums/chords
- automation lanes and mute tracks
- loop bracket for loop region selection
- export modal placeholder

known issues:
- drum pattern changes don't update audio engine
- timeline keyframes not connected to scheduler
- some UI bugs remain

this is a checkpoint commit for further iteration
2026-01-20 18:22:10 -07:00

164 lines
5.3 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Download, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ExportModalProps {
durationBars: number;
bpm: number;
onExport: () => Promise<Blob>;
onClose: () => void;
className?: string;
}
export function ExportModal({
durationBars,
bpm,
onExport,
onClose,
className,
}: ExportModalProps) {
const [filename, setFilename] = useState('lofi-beat');
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const secondsPerBar = (60 / bpm) * 4;
const totalSeconds = durationBars * secondsPerBar;
const estimatedSizeKB = Math.round(totalSeconds * 44.1 * 2 * 2);
const estimatedSizeMB = (estimatedSizeKB / 1024).toFixed(1);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleExport = useCallback(async () => {
setIsExporting(true);
setError(null);
setProgress(0);
const progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + Math.random() * 10, 90));
}, 200);
try {
const blob = await onExport();
setProgress(100);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(onClose, 500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed');
} finally {
clearInterval(progressInterval);
setIsExporting(false);
}
}, [filename, onExport, onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card className={cn('w-full max-w-sm', className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Export Audio</CardTitle>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<div className="flex items-center gap-2">
<input
id="filename"
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className={cn(
'flex-1 px-3 py-2 text-sm rounded-md bg-muted border border-border',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<span className="text-sm text-muted-foreground">.wav</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Duration:</span>
<span className="ml-2 font-mono">{formatDuration(totalSeconds)}</span>
</div>
<div>
<span className="text-muted-foreground">Size:</span>
<span className="ml-2 font-mono">~{estimatedSizeMB} MB</span>
</div>
<div>
<span className="text-muted-foreground">Format:</span>
<span className="ml-2">WAV 44.1kHz 16-bit</span>
</div>
<div>
<span className="text-muted-foreground">Bars:</span>
<span className="ml-2 font-mono">{durationBars}</span>
</div>
</div>
{isExporting && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Rendering...</span>
<span className="font-mono">{Math.round(progress)}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{error && (
<div className="p-2 text-sm text-red-500 bg-red-500/10 rounded-md">
{error}
</div>
)}
<Button
className="w-full"
onClick={handleExport}
disabled={isExporting || !filename}
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rendering...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Export
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}