504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, CircleArrowOutUpRight, Upload, Plus } from 'lucide-react';
|
||
import type { Track as TrackType } from '@/types/track';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Slider } from '@/components/ui/Slider';
|
||
import { cn } from '@/lib/utils/cn';
|
||
|
||
export interface TrackProps {
|
||
track: TrackType;
|
||
zoom: number;
|
||
currentTime: number;
|
||
duration: number;
|
||
isSelected?: boolean;
|
||
onSelect?: () => void;
|
||
onToggleMute: () => void;
|
||
onToggleSolo: () => void;
|
||
onToggleCollapse: () => void;
|
||
onVolumeChange: (volume: number) => void;
|
||
onPanChange: (pan: number) => void;
|
||
onRemove: () => void;
|
||
onNameChange: (name: string) => void;
|
||
onSeek?: (time: number) => void;
|
||
onLoadAudio?: (buffer: AudioBuffer) => void;
|
||
onToggleEffect?: (effectId: string) => void;
|
||
onRemoveEffect?: (effectId: string) => void;
|
||
}
|
||
|
||
export function Track({
|
||
track,
|
||
zoom,
|
||
currentTime,
|
||
duration,
|
||
isSelected,
|
||
onSelect,
|
||
onToggleMute,
|
||
onToggleSolo,
|
||
onToggleCollapse,
|
||
onVolumeChange,
|
||
onPanChange,
|
||
onRemove,
|
||
onNameChange,
|
||
onSeek,
|
||
onLoadAudio,
|
||
onToggleEffect,
|
||
onRemoveEffect,
|
||
}: TrackProps) {
|
||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||
const [isEditingName, setIsEditingName] = React.useState(false);
|
||
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
|
||
const [showDevices, setShowDevices] = React.useState(true);
|
||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||
|
||
const handleNameClick = () => {
|
||
setIsEditingName(true);
|
||
setNameInput(String(track.name || 'Untitled Track'));
|
||
};
|
||
|
||
const handleNameBlur = () => {
|
||
setIsEditingName(false);
|
||
if (nameInput.trim()) {
|
||
onNameChange(nameInput.trim());
|
||
} else {
|
||
setNameInput(String(track.name || 'Untitled Track'));
|
||
}
|
||
};
|
||
|
||
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
inputRef.current?.blur();
|
||
} else if (e.key === 'Escape') {
|
||
setNameInput(String(track.name || 'Untitled Track'));
|
||
setIsEditingName(false);
|
||
}
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
if (isEditingName && inputRef.current) {
|
||
inputRef.current.focus();
|
||
inputRef.current.select();
|
||
}
|
||
}, [isEditingName]);
|
||
|
||
// Draw waveform
|
||
React.useEffect(() => {
|
||
if (!track.audioBuffer || !canvasRef.current) return;
|
||
|
||
const canvas = canvasRef.current;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
|
||
// Use parent container's size since canvas is absolute positioned
|
||
const parent = canvas.parentElement;
|
||
if (!parent) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = parent.getBoundingClientRect();
|
||
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const width = rect.width;
|
||
const height = rect.height;
|
||
|
||
// Clear canvas
|
||
ctx.fillStyle = 'rgb(15, 23, 42)';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const buffer = track.audioBuffer;
|
||
const channelData = buffer.getChannelData(0);
|
||
const samplesPerPixel = Math.floor(buffer.length / (width * zoom));
|
||
|
||
// Draw waveform
|
||
ctx.fillStyle = track.color;
|
||
ctx.strokeStyle = track.color;
|
||
ctx.lineWidth = 1;
|
||
|
||
for (let x = 0; x < width; x++) {
|
||
const startSample = Math.floor(x * samplesPerPixel);
|
||
const endSample = Math.floor((x + 1) * samplesPerPixel);
|
||
|
||
let min = 1.0;
|
||
let max = -1.0;
|
||
|
||
for (let i = startSample; i < endSample && i < channelData.length; i++) {
|
||
const sample = channelData[i];
|
||
if (sample < min) min = sample;
|
||
if (sample > max) max = sample;
|
||
}
|
||
|
||
const y1 = (height / 2) * (1 - max);
|
||
const y2 = (height / 2) * (1 - min);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, y1);
|
||
ctx.lineTo(x, y2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw center line
|
||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, height / 2);
|
||
ctx.lineTo(width, height / 2);
|
||
ctx.stroke();
|
||
|
||
// Draw playhead
|
||
if (duration > 0) {
|
||
const playheadX = (currentTime / duration) * width;
|
||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(playheadX, 0);
|
||
ctx.lineTo(playheadX, height);
|
||
ctx.stroke();
|
||
}
|
||
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration]);
|
||
|
||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
if (!onSeek || !duration) return;
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const clickTime = (x / rect.width) * duration;
|
||
onSeek(clickTime);
|
||
};
|
||
|
||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !onLoadAudio) return;
|
||
|
||
try {
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const audioContext = new AudioContext();
|
||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||
onLoadAudio(audioBuffer);
|
||
|
||
// Update track name to filename if it's still default
|
||
if (track.name === 'New Track' || track.name === 'Untitled Track') {
|
||
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||
onNameChange(fileName);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load audio file:', error);
|
||
}
|
||
|
||
// Reset input
|
||
e.target.value = '';
|
||
};
|
||
|
||
const handleLoadAudioClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const [isDragging, setIsDragging] = React.useState(false);
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const handleDrop = async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDragging(false);
|
||
|
||
const file = e.dataTransfer.files?.[0];
|
||
if (!file || !onLoadAudio) return;
|
||
|
||
// Check if it's an audio file
|
||
if (!file.type.startsWith('audio/')) {
|
||
console.warn('Dropped file is not an audio file');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const audioContext = new AudioContext();
|
||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||
onLoadAudio(audioBuffer);
|
||
|
||
// Update track name to filename if it's still default
|
||
if (track.name === 'New Track' || track.name === 'Untitled Track') {
|
||
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||
onNameChange(fileName);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load audio file:', error);
|
||
}
|
||
};
|
||
|
||
const trackHeight = track.collapsed ? 48 : track.height;
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className={cn(
|
||
'flex',
|
||
isSelected && 'ring-2 ring-primary ring-inset'
|
||
)}
|
||
style={{ height: trackHeight }}
|
||
>
|
||
{/* Left: Track Control Panel (Fixed Width) */}
|
||
<div
|
||
className="w-72 flex-shrink-0 bg-card border-r border-border border-b border-border p-3 flex flex-col gap-2"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Track Name & Collapse Toggle */}
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onToggleCollapse}
|
||
title={track.collapsed ? 'Expand track' : 'Collapse track'}
|
||
>
|
||
{track.collapsed ? (
|
||
<ChevronRight className="h-4 w-4" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
|
||
<div
|
||
className="w-1 h-8 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: track.color }}
|
||
/>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
{isEditingName ? (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={nameInput}
|
||
onChange={(e) => setNameInput(e.target.value)}
|
||
onBlur={handleNameBlur}
|
||
onKeyDown={handleNameKeyDown}
|
||
className="w-full px-2 py-1 text-sm font-medium bg-background border border-border rounded"
|
||
/>
|
||
) : (
|
||
<div
|
||
onClick={handleNameClick}
|
||
className="px-2 py-1 text-sm font-medium text-foreground truncate cursor-pointer hover:bg-accent rounded"
|
||
title={String(track.name || 'Untitled Track')}
|
||
>
|
||
{String(track.name || 'Untitled Track')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Solo Button */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onToggleSolo}
|
||
title="Solo track"
|
||
className={cn(track.solo && 'bg-yellow-500/20 hover:bg-yellow-500/30')}
|
||
>
|
||
<Headphones className={cn('h-4 w-4', track.solo && 'text-yellow-500')} />
|
||
</Button>
|
||
|
||
{/* Mute Button */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onToggleMute}
|
||
title="Mute track"
|
||
className={cn(track.mute && 'bg-red-500/20 hover:bg-red-500/30')}
|
||
>
|
||
{track.mute ? (
|
||
<VolumeX className="h-4 w-4 text-red-500" />
|
||
) : (
|
||
<Volume2 className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
|
||
{/* Remove Button */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={onRemove}
|
||
title="Remove track"
|
||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Track Controls - Only show when not collapsed */}
|
||
{!track.collapsed && (
|
||
<>
|
||
{/* Volume */}
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-muted-foreground flex items-center gap-1 w-16 flex-shrink-0">
|
||
<Volume2 className="h-3.5 w-3.5" />
|
||
Volume
|
||
</label>
|
||
<div className="flex-1">
|
||
<Slider
|
||
value={track.volume}
|
||
onChange={onVolumeChange}
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground w-10 text-right flex-shrink-0">
|
||
{Math.round(track.volume * 100)}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Pan */}
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-muted-foreground flex items-center gap-1 w-16 flex-shrink-0">
|
||
<CircleArrowOutUpRight className="h-3 w-3" />
|
||
Pan
|
||
</label>
|
||
<div className="flex-1">
|
||
<Slider
|
||
value={track.pan}
|
||
onChange={onPanChange}
|
||
min={-1}
|
||
max={1}
|
||
step={0.01}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground w-10 text-right flex-shrink-0">
|
||
{track.pan === 0
|
||
? 'C'
|
||
: track.pan < 0
|
||
? `L${Math.abs(Math.round(track.pan * 100))}`
|
||
: `R${Math.round(track.pan * 100)}`}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Devices/Effects Section */}
|
||
<div className="pt-2">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<button
|
||
onClick={() => setShowDevices(!showDevices)}
|
||
className="flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary transition-colors"
|
||
>
|
||
<span>Devices ({track.effectChain.effects.length})</span>
|
||
{showDevices ? (
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronRight className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => {
|
||
// TODO: Open effect browser/selector dialog
|
||
console.log('Add effect clicked for track:', track.id);
|
||
}}
|
||
title="Add effect"
|
||
className="h-5 w-5"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
{showDevices && (
|
||
<div className="space-y-1">
|
||
{track.effectChain.effects.length === 0 ? (
|
||
<div className="text-xs text-muted-foreground text-center py-2">
|
||
No devices
|
||
</div>
|
||
) : (
|
||
track.effectChain.effects.map((effect) => (
|
||
<div
|
||
key={effect.id}
|
||
className={cn(
|
||
'flex items-center justify-between px-2 py-1.5 rounded text-xs',
|
||
effect.enabled
|
||
? 'bg-accent/50 text-foreground'
|
||
: 'bg-muted/50 text-muted-foreground'
|
||
)}
|
||
>
|
||
<span className="truncate">{effect.name}</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => onToggleEffect?.(effect.id)}
|
||
className="hover:text-primary"
|
||
title={effect.enabled ? 'Disable' : 'Enable'}
|
||
>
|
||
{effect.enabled ? '●' : '○'}
|
||
</button>
|
||
<button
|
||
onClick={() => onRemoveEffect?.(effect.id)}
|
||
className="hover:text-destructive"
|
||
title="Remove"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right: Waveform Area (Flexible Width) */}
|
||
<div
|
||
className="flex-1 relative bg-slate-900 border-b border-border cursor-pointer"
|
||
onClick={onSelect}
|
||
>
|
||
{track.audioBuffer ? (
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="absolute inset-0 w-full h-full cursor-pointer"
|
||
onClick={handleCanvasClick}
|
||
/>
|
||
) : (
|
||
!track.collapsed && (
|
||
<>
|
||
<div
|
||
className={cn(
|
||
"absolute inset-0 flex flex-col items-center justify-center text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer group",
|
||
isDragging ? "bg-primary/20 text-primary border-2 border-primary border-dashed" : "hover:bg-accent/50"
|
||
)}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleLoadAudioClick();
|
||
}}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<Upload className="h-6 w-6 mb-2 opacity-50 group-hover:opacity-100" />
|
||
<p>{isDragging ? 'Drop audio file here' : 'Click to load audio file'}</p>
|
||
<p className="text-xs opacity-75 mt-1">or drag & drop</p>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="audio/*"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
/>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|