feat: redesign track layout to DAW-style with left control panel

Major UX improvement inspired by Audacity/Ableton Live:

- Each track now has 2 sections: left control panel (fixed 288px) and right waveform (flexible)
- Left panel contains: track name, color indicator, collapse toggle, volume, pan, solo, mute, delete
- TrackHeader component functionality moved directly into Track component
- Removed redundant track controls from SidePanel
- SidePanel now simplified to show global actions and effect chain
- Track controls are always visible on the left, waveform scrolls horizontally on the right
- Collapsed tracks show only the header row (48px height)
- Much better UX for multi-track editing

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 06:41:22 +01:00
parent f42e5d4556
commit e376f3b0b4
2 changed files with 199 additions and 158 deletions

View File

@@ -9,13 +9,10 @@ import {
Trash2,
Link2,
FolderOpen,
Volume2,
Music2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
import { formatDuration } from '@/lib/audio/decoder';
import type { Track } from '@/types/track';
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
import { EffectRack } from '@/components/effects/EffectRack';
@@ -165,122 +162,17 @@ export function SidePanel({
)}
</div>
{/* Track List */}
{/* Track List - Simplified */}
{tracks.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Tracks ({tracks.length})
</h3>
<div className="space-y-2">
{tracks.map((track) => {
const isSelected = selectedTrackId === track.id;
return (
<div
key={track.id}
className={cn(
'p-3 rounded-lg border transition-colors cursor-pointer',
isSelected
? 'bg-primary/10 border-primary'
: 'bg-secondary/30 border-border hover:border-primary/50'
)}
onClick={() => onSelectTrack(isSelected ? null : track.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{String(track.name || 'Untitled Track')}
</div>
{track.audioBuffer && (
<div className="text-xs text-muted-foreground">
{formatDuration(track.audioBuffer.duration)}
</div>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onRemoveTrack(track.id);
}}
title="Remove track"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
{/* Track Controls - Always visible */}
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
{/* Volume */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground flex items-center gap-1">
<Volume2 className="h-3 w-3" />
Volume
</label>
<span className="text-xs text-muted-foreground">
{Math.round(track.volume * 100)}%
</span>
</div>
<Slider
value={track.volume}
onChange={(value) => onUpdateTrack(track.id, { volume: value })}
min={0}
max={1}
step={0.01}
/>
</div>
{/* Pan */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Pan</label>
<span className="text-xs text-muted-foreground">
{track.pan === 0
? 'C'
: track.pan < 0
? `L${Math.round(Math.abs(track.pan) * 100)}`
: `R${Math.round(track.pan * 100)}`}
</span>
</div>
<Slider
value={track.pan}
onChange={(value) => onUpdateTrack(track.id, { pan: value })}
min={-1}
max={1}
step={0.01}
/>
</div>
{/* Solo / Mute */}
<div className="flex gap-2">
<Button
variant={track.solo ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateTrack(track.id, { solo: !track.solo });
}}
className="flex-1 text-xs"
>
Solo
</Button>
<Button
variant={track.mute ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateTrack(track.id, { mute: !track.mute });
}}
className="flex-1 text-xs"
>
Mute
</Button>
</div>
</div>
</div>
);
})}
<div className="text-xs text-muted-foreground">
<p className="mb-2">
Track controls are located on the left side of each track in the timeline.
</p>
<p>Click a track to select it and apply effects from the Effect Chain tab.</p>
</div>
</div>
) : (

View File

@@ -1,8 +1,10 @@
'use client';
import * as React from 'react';
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import type { Track as TrackType } from '@/types/track';
import { TrackHeader } from './TrackHeader';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
export interface TrackProps {
@@ -40,6 +42,39 @@ export function Track({
}: TrackProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [isEditingName, setIsEditingName] = React.useState(false);
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
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(() => {
@@ -123,59 +158,173 @@ export function Track({
onSeek(clickTime);
};
if (track.collapsed) {
return (
<div
className={cn(
'border-b border-border cursor-pointer',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={onSelect}
>
<TrackHeader
track={track}
onToggleMute={onToggleMute}
onToggleSolo={onToggleSolo}
onToggleCollapse={onToggleCollapse}
onVolumeChange={onVolumeChange}
onPanChange={onPanChange}
onRemove={onRemove}
onNameChange={onNameChange}
/>
</div>
);
}
const trackHeight = track.collapsed ? 48 : track.height;
return (
<div
ref={containerRef}
className={cn(
'border-b border-border cursor-pointer',
'flex border-b border-border',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={onSelect}
style={{ height: trackHeight }}
>
<TrackHeader
track={track}
onToggleMute={onToggleMute}
onToggleSolo={onToggleSolo}
onToggleCollapse={onToggleCollapse}
onVolumeChange={onVolumeChange}
onPanChange={onPanChange}
onRemove={onRemove}
onNameChange={onNameChange}
/>
<div className="relative" style={{ height: track.height }}>
{track.audioBuffer ? (
<canvas
ref={canvasRef}
className="w-full h-full cursor-pointer"
onClick={handleCanvasClick}
{/* Left: Track Control Panel (Fixed Width) */}
<div
className="w-72 flex-shrink-0 bg-card border-r border-border p-3 space-y-2 overflow-y-auto"
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 items-center justify-center h-full text-sm text-muted-foreground">
No audio loaded
<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>
<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="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground flex items-center gap-1">
<Volume2 className="h-3 w-3" />
Volume
</label>
<span className="text-xs text-muted-foreground">
{Math.round(track.volume * 100)}%
</span>
</div>
<Slider
value={track.volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
/>
</div>
{/* Pan */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Pan</label>
<span className="text-xs text-muted-foreground">
{track.pan === 0
? 'C'
: track.pan < 0
? `L${Math.round(Math.abs(track.pan) * 100)}`
: `R${Math.round(track.pan * 100)}`}
</span>
</div>
<Slider
value={track.pan}
onChange={onPanChange}
min={-1}
max={1}
step={0.01}
/>
</div>
{/* Solo / Mute Buttons */}
<div className="flex gap-2">
<Button
variant={track.solo ? 'default' : 'outline'}
size="sm"
onClick={onToggleSolo}
className={cn(
'flex-1 text-xs',
track.solo && 'bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500'
)}
>
<Headphones className="h-3.5 w-3.5 mr-1.5" />
Solo
</Button>
<Button
variant={track.mute ? 'default' : 'outline'}
size="sm"
onClick={onToggleMute}
className={cn(
'flex-1 text-xs',
track.mute && 'bg-red-500/20 hover:bg-red-500/30 text-red-500'
)}
>
{track.mute ? (
<VolumeX className="h-3.5 w-3.5 mr-1.5" />
) : (
<Volume2 className="h-3.5 w-3.5 mr-1.5" />
)}
Mute
</Button>
</div>
</>
)}
</div>
{/* Right: Waveform Area (Flexible Width) */}
<div
className="flex-1 relative bg-slate-900 cursor-pointer"
onClick={onSelect}
>
{!track.collapsed && (
<>
{track.audioBuffer ? (
<canvas
ref={canvasRef}
className="w-full h-full cursor-pointer"
onClick={handleCanvasClick}
/>
) : (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
No audio loaded
</div>
)}
</>
)}
</div>
</div>