Files
audio-ui/components/tracks/Track.tsx
Sebastian Krüger f3f5b65e1e feat: implement Ableton Live-style DAW layout
Major UX refactor to match professional DAW workflows (Ableton/Bitwig):

**Layout Changes:**
- Removed sidebar completely
- Track actions moved to header toolbar (Add/Import/Clear All)
- Each track now shows its own devices/effects in the track strip
- Master section moved to bottom footer area
- Full-width waveform display

**Track Strip (left panel):**
- Track name (editable inline)
- Color indicator
- Collapse/Solo/Mute/Delete buttons
- Volume slider with percentage
- Pan slider with L/C/R indicator
- Collapsible "Devices" section showing track effects
  - Shows effect count in header
  - Each effect card shows: name, enable/disable toggle, remove button
  - Effects are colored based on enabled/disabled state
  - Click to expand/collapse devices section

**Master Section (bottom):**
- Transport controls (Play/Pause/Stop) with timeline
- Master volume control
- Master effects placeholder (to be implemented)

**Benefits:**
- True DAW experience like Ableton Live
- Each track is self-contained with its own effect chain
- No context switching between tabs
- Effects are always visible for each track
- More screen space for waveforms
- Professional mixer-style layout

Note: Effects are visible but not yet applied to audio - that's next!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 07:46:27 +01:00

490 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 } 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 border-t border-border">
<button
onClick={() => setShowDevices(!showDevices)}
className="flex items-center justify-between w-full text-xs font-medium text-foreground hover:text-primary transition-colors mb-2"
>
<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>
{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>
);
}