Files
audio-ui/components/tracks/Track.tsx

504 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}