forked from averyfelts/Lofi_Generator
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
164 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|