feat: streamline track and master controls layout consistency

- Streamlined track controls and master controls to same width (240px)
- Fixed track controls container to use full width of parent column
- Matched TrackControls card structure with MasterControls (gap-3, no w-full/h-full)
- Updated outer container padding from p-2 to p-4 with gap-4
- Adjusted track controls wrapper to center content instead of stretching
- Added max-width constraint to PlaybackControls to prevent width changes
- Centered transport control buttons in footer

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 16:32:49 +01:00
parent 854e64b4ec
commit 5d9e02fe95
9 changed files with 457 additions and 214 deletions

View File

@@ -69,10 +69,13 @@ export function AutomationHeader({
return (
<div
className={cn(
'relative flex items-center gap-2 px-2 py-1 bg-muted/50 border-b border-border/30 flex-shrink-0',
'flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30 flex-shrink-0',
className
)}
>
{/* Automation label - always visible */}
<span className="text-xs font-medium flex-shrink-0">Automation</span>
{/* Color indicator */}
{color && (
<div

View File

@@ -38,9 +38,9 @@ export function AutomationLane({
(time: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (time / duration) * width * zoom;
return (time / duration) * width;
},
[duration, zoom]
[duration]
);
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
@@ -58,9 +58,9 @@ export function AutomationLane({
(x: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (x / (width * zoom)) * duration;
return (x / width) * duration;
},
[duration, zoom]
[duration]
);
// Convert Y pixel position to value (0-1)
@@ -209,7 +209,7 @@ export function AutomationLane({
const width = rect.width;
// Calculate new time and value
const timePerPixel = duration / (width * zoom);
const timePerPixel = duration / width;
const valuePerPixel = 1 / lane.height;
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
@@ -217,7 +217,7 @@ export function AutomationLane({
onUpdatePoint(pointId, { time: newTime, value: newValue });
},
[lane.points, lane.height, duration, zoom, onUpdatePoint]
[lane.points, lane.height, duration, onUpdatePoint]
);
const handlePointDragEnd = React.useCallback(() => {

View File

@@ -1569,7 +1569,7 @@ export function AudioEditor() {
</main>
{/* Right Sidebar - Master Controls & Analyzers */}
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col p-4 gap-4 w-[280px]">
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Master Controls */}
<div className="flex items-center justify-center">
<MasterControls
@@ -1655,7 +1655,7 @@ export function AudioEditor() {
{/* Analyzer Display */}
<div className="flex-1 min-h-[360px] flex items-start justify-center">
<div className="w-[192px]">
<div className="w-[178px]">
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
{analyzerView === 'spectrogram' && <Spectrogram analyserNode={masterAnalyser} />}
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}

View File

@@ -71,7 +71,7 @@ export function PlaybackControls({
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={cn('space-y-4', className)}>
<div className={cn('space-y-4 w-full max-w-2xl', className)}>
{/* Timeline Slider */}
<div className="space-y-2">
<input
@@ -161,7 +161,7 @@ export function PlaybackControls({
)}
{/* Transport Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"

View File

@@ -111,12 +111,7 @@ export function Track({
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 [themeKey, setThemeKey] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const [isResizing, setIsResizing] = React.useState(false);
const resizeStartRef = React.useRef({ y: 0, height: 0 });
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
@@ -286,36 +281,6 @@ export function Track({
onUpdateTrack,
]);
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]);
// Listen for theme changes
React.useEffect(() => {
const observer = new MutationObserver(() => {
@@ -686,7 +651,7 @@ export function Track({
<>
<div
className={cn(
"w-48 flex-shrink-0 border-b border-r-4 p-2 flex flex-col gap-2 min-h-0 transition-all duration-200 cursor-pointer border-border",
"w-full flex-shrink-0 border-b border-r-4 p-4 flex flex-col gap-4 min-h-0 transition-all duration-200 cursor-pointer border-border",
isSelected
? "bg-primary/10 border-r-primary"
: "bg-card border-r-transparent hover:bg-accent/30",
@@ -697,76 +662,38 @@ export function Track({
if (onSelect) onSelect();
}}
>
{/* Track Name Row - Integrated collapse (DAW style) */}
<div
className={cn(
"group flex items-center gap-1.5 px-1 py-0.5 rounded cursor-pointer transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-accent/50",
)}
onClick={(e) => {
if (!isEditingName) {
{/* Collapsed Header */}
{track.collapsed && (
<div
className={cn(
"group flex items-center gap-1.5 px-2 py-1 h-full w-full cursor-pointer transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-accent/50",
)}
onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}
}}
title={track.collapsed ? "Expand track" : "Collapse track"}
>
{/* Small triangle indicator */}
<div
className={cn(
"flex-shrink-0 transition-colors",
isSelected
? "text-primary"
: "text-muted-foreground group-hover:text-foreground",
)}
}}
title="Expand track"
>
{track.collapsed ? (
<ChevronRight className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<ChevronRight className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<div
className="h-4 w-0.5 rounded-full flex-shrink-0"
style={{ backgroundColor: track.color }}
/>
<span className="text-xs font-semibold text-foreground truncate flex-1">
{String(track.name || "Untitled Track")}
</span>
</div>
{/* Color stripe (thicker when selected) */}
<div
className={cn(
"h-5 rounded-full flex-shrink-0 transition-all",
isSelected ? "w-1" : "w-0.5",
)}
style={{ backgroundColor: track.color }}
></div>
<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}
onClick={(e) => e.stopPropagation()}
className="w-full px-1 py-0.5 text-xs font-semibold bg-background border border-border rounded"
/>
) : (
<div
onClick={(e) => {
e.stopPropagation();
handleNameClick();
}}
className="px-1 py-0.5 text-xs font-semibold text-foreground truncate"
title={String(track.name || "Untitled Track")}
>
{String(track.name || "Untitled Track")}
</div>
)}
</div>
</div>
)}
{/* Track Controls - Only show when not collapsed */}
{!track.collapsed && (
<div className="flex-1 flex flex-col items-center justify-between min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col items-center justify-center min-h-0 overflow-hidden">
{/* Integrated Track Controls (Pan + Fader + Buttons) */}
<TrackControls
trackName={track.name}
trackColor={track.color}
collapsed={track.collapsed}
volume={track.volume}
pan={track.pan}
peakLevel={
@@ -785,6 +712,8 @@ export function Track({
showAutomation={track.automation?.showAutomation}
showEffects={track.showEffects}
isRecording={isRecording}
onNameChange={onNameChange}
onToggleCollapse={onToggleCollapse}
onVolumeChange={onVolumeChange}
onPanChange={onPanChange}
onMuteToggle={onToggleMute}
@@ -844,14 +773,14 @@ export function Track({
: "100%",
}}
>
{/* Delete Button - Top Right Overlay */}
{/* Delete Button - Top Right Overlay - Stays fixed when scrolling */}
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className={cn(
"absolute top-2 right-2 z-20 h-6 w-6 rounded flex items-center justify-center transition-all",
"sticky top-2 right-2 float-right z-20 h-6 w-6 rounded flex items-center justify-center transition-all",
"bg-card/80 hover:bg-destructive/90 text-muted-foreground hover:text-white",
"border border-border/50 hover:border-destructive",
"backdrop-blur-sm shadow-sm hover:shadow-md",
@@ -875,11 +804,12 @@ export function Track({
) : (
!track.collapsed && (
<>
{/* Empty state - clickable area for upload with drag & drop */}
<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",
"absolute inset-0 w-full h-full transition-colors cursor-pointer",
isDragging
? "bg-primary/20 text-primary border-2 border-primary border-dashed"
? "bg-primary/20 border-2 border-primary border-dashed"
: "hover:bg-accent/50",
)}
onClick={(e) => {
@@ -889,15 +819,7 @@ export function Track({
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"
@@ -909,6 +831,16 @@ export function Track({
)
)}
</div>
{/* Import Dialog - Also needed in waveform-only mode */}
<ImportDialog
open={showImportDialog}
onClose={handleImportCancel}
onImport={handleImport}
fileName={pendingFile?.name}
sampleRate={fileMetadata.sampleRate}
channels={fileMetadata.channels}
/>
</div>
);
}

View File

@@ -1,12 +1,15 @@
'use client';
import * as React from 'react';
import { Circle, Headphones, MoreHorizontal } from 'lucide-react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { TrackFader } from './TrackFader';
import { cn } from '@/lib/utils/cn';
export interface TrackControlsProps {
trackName: string;
trackColor: string;
collapsed: boolean;
volume: number;
pan: number;
peakLevel: number;
@@ -17,6 +20,8 @@ export interface TrackControlsProps {
showAutomation?: boolean;
showEffects?: boolean;
isRecording?: boolean;
onNameChange: (name: string) => void;
onToggleCollapse: () => void;
onVolumeChange: (volume: number) => void;
onPanChange: (pan: number) => void;
onMuteToggle: () => void;
@@ -32,6 +37,9 @@ export interface TrackControlsProps {
}
export function TrackControls({
trackName,
trackColor,
collapsed,
volume,
pan,
peakLevel,
@@ -42,6 +50,8 @@ export function TrackControls({
showAutomation = false,
showEffects = false,
isRecording = false,
onNameChange,
onToggleCollapse,
onVolumeChange,
onPanChange,
onMuteToggle,
@@ -55,11 +65,77 @@ export function TrackControls({
onPanTouchEnd,
className,
}: TrackControlsProps) {
const [isEditingName, setIsEditingName] = React.useState(false);
const [editName, setEditName] = React.useState(trackName);
const handleNameClick = () => {
setIsEditingName(true);
setEditName(trackName);
};
const handleNameBlur = () => {
setIsEditingName(false);
if (editName.trim() && editName !== trackName) {
onNameChange(editName.trim());
} else {
setEditName(trackName);
}
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleNameBlur();
} else if (e.key === 'Escape') {
setIsEditingName(false);
setEditName(trackName);
}
};
return (
<div className={cn(
'flex flex-col items-center justify-between h-full px-3 pt-2 pb-4 bg-card/50 border-2 border-accent/50 rounded-lg',
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
className
)}>
{/* Track Name Header with Collapse Chevron */}
<div className="flex items-center gap-1 w-full">
<button
onClick={onToggleCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
title={collapsed ? 'Expand track' : 'Collapse track'}
>
{collapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 flex items-center justify-center min-w-0">
{isEditingName ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
autoFocus
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center bg-transparent border-b focus:outline-none px-1"
style={{ color: trackColor, borderColor: trackColor }}
/>
) : (
<div
onClick={handleNameClick}
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center cursor-text hover:bg-accent/10 px-1 rounded transition-colors truncate"
style={{ color: trackColor }}
title="Click to edit track name"
>
{trackName}
</div>
)}
</div>
{/* Spacer to balance the chevron and center the label */}
<div className="p-0.5 flex-shrink-0 w-4" />
</div>
{/* Pan Control - Top */}
<div className="flex justify-center w-full">
<CircularKnob
@@ -71,7 +147,7 @@ export function TrackControls({
max={1}
step={0.01}
label="PAN"
size={40}
size={48}
formatValue={(value: number) => {
if (Math.abs(value) < 0.01) return 'C';
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
@@ -94,14 +170,14 @@ export function TrackControls({
{/* Control Buttons - Bottom */}
<div className="flex flex-col gap-1 w-full">
{/* Control Buttons Row 1: R/S/M */}
<div className="flex items-center gap-0.5 w-full justify-center">
{/* Control Buttons Row 1: R/M/S */}
<div className="flex items-center gap-1 w-full justify-center">
{/* Record Arm */}
{onRecordToggle && (
<button
onClick={onRecordToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isRecordEnabled
? 'bg-red-500 text-white shadow-md shadow-red-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50',
@@ -109,23 +185,7 @@ export function TrackControls({
)}
title="Arm track for recording"
>
<Circle className="h-2.5 w-2.5 fill-current" />
</button>
)}
{/* Solo Button */}
{onSoloToggle && (
<button
onClick={onSoloToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
isSolo
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title="Solo track"
>
<Headphones className="h-2.5 w-2.5" />
<Circle className="h-3 w-3 fill-current" />
</button>
)}
@@ -133,7 +193,7 @@ export function TrackControls({
<button
onClick={onMuteToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isMuted
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
@@ -142,6 +202,22 @@ export function TrackControls({
>
M
</button>
{/* Solo Button */}
{onSoloToggle && (
<button
onClick={onSoloToggle}
className={cn(
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isSolo
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title="Solo track"
>
<Headphones className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>

View File

@@ -108,9 +108,9 @@ export function TrackFader({
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
return (
<div className={cn('flex gap-2', className)} style={{ marginLeft: '16px' }}>
<div className={cn('flex gap-3', className)} style={{ marginLeft: '16px' }}>
{/* dB Labels (Left) */}
<div className="flex flex-col justify-between text-[9px] font-mono text-muted-foreground py-1">
<div className="flex flex-col justify-between text-[10px] font-mono text-muted-foreground py-1">
<span>0</span>
<span>-12</span>
<span>-24</span>
@@ -120,12 +120,12 @@ export function TrackFader({
{/* Fader Container */}
<div
ref={containerRef}
className="relative w-10 h-32 bg-background/50 rounded-md border border-border/50 cursor-pointer"
className="relative w-12 h-40 bg-background/50 rounded-md border border-border/50 cursor-pointer"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* Peak Meter (Horizontal Bar - Top) */}
<div className="absolute inset-x-1.5 top-1.5 h-2.5 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div className="absolute inset-x-2 top-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div
className="absolute left-0 top-0 bottom-0 transition-all duration-75 ease-out"
style={{ width: `${Math.max(0, Math.min(100, peakWidth))}%` }}
@@ -140,7 +140,7 @@ export function TrackFader({
</div>
{/* RMS Meter (Horizontal Bar - Bottom) */}
<div className="absolute inset-x-1.5 bottom-1.5 h-2.5 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div className="absolute inset-x-2 bottom-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div
className="absolute left-0 top-0 bottom-0 transition-all duration-150 ease-out"
style={{ width: `${Math.max(0, Math.min(100, rmsWidth))}%` }}
@@ -155,26 +155,26 @@ export function TrackFader({
</div>
{/* Fader Track */}
<div className="absolute top-6 bottom-6 left-1/2 -translate-x-1/2 w-1 bg-muted/50 rounded-full" />
<div className="absolute top-8 bottom-8 left-1/2 -translate-x-1/2 w-1.5 bg-muted/50 rounded-full" />
{/* Fader Handle */}
<div
className="absolute left-1/2 -translate-x-1/2 w-9 h-3.5 bg-primary/80 border-2 border-primary rounded-md shadow-lg cursor-grab active:cursor-grabbing pointer-events-none transition-all"
className="absolute left-1/2 -translate-x-1/2 w-10 h-4 bg-primary/80 border-2 border-primary rounded-md shadow-lg cursor-grab active:cursor-grabbing pointer-events-none transition-all"
style={{
// Inverted: value 1 = top, value 0 = bottom
top: `calc(${(1 - value) * 100}% - 0.4375rem)`,
top: `calc(${(1 - value) * 100}% - 0.5rem)`,
}}
>
{/* Handle grip lines */}
<div className="absolute inset-0 flex items-center justify-center gap-0.5">
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
</div>
</div>
{/* dB Scale Markers */}
<div className="absolute inset-0 px-1.5 py-6 pointer-events-none">
<div className="absolute inset-0 px-2 py-8 pointer-events-none">
<div className="relative h-full">
{/* -12 dB */}
<div className="absolute left-0 right-0 h-px bg-border/20" style={{ top: '50%' }} />
@@ -186,17 +186,49 @@ export function TrackFader({
</div>
</div>
{/* Value Display (Right) */}
<div className="flex flex-col justify-center items-start text-[9px] font-mono">
{/* Value and Level Display (Right) */}
<div className="flex flex-col justify-between items-start text-[9px] font-mono py-1 w-[36px]">
{/* Current dB Value */}
<div className={cn(
'font-bold text-[10px] w-[32px]',
'font-bold text-[11px]',
valueDb > -3 ? 'text-red-500' :
valueDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Peak Level */}
<div className="flex flex-col items-start">
<span className="text-muted-foreground/60">PK</span>
<span className={cn(
'font-mono text-[10px]',
peakDb > -3 ? 'text-red-500' :
peakDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{peakDb > -60 ? `${peakDb.toFixed(1)}` : '-∞'}
</span>
</div>
{/* RMS Level */}
<div className="flex flex-col items-start">
<span className="text-muted-foreground/60">RM</span>
<span className={cn(
'font-mono text-[10px]',
rmsDb > -3 ? 'text-red-500' :
rmsDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{rmsDb > -60 ? `${rmsDb.toFixed(1)}` : '-∞'}
</span>
</div>
{/* dB Label */}
<span className="text-muted-foreground/60 text-[8px]">dB</span>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Plus, Upload, ChevronDown, ChevronRight, X, Eye, EyeOff } from 'lucide-react';
import { Plus, Upload, ChevronDown, ChevronRight, ChevronUp, X, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import { Track } from './Track';
@@ -61,6 +61,11 @@ export function TrackList({
const waveformScrollRef = React.useRef<HTMLDivElement>(null);
const controlsScrollRef = React.useRef<HTMLDivElement>(null);
// Refs for horizontal scroll synchronization (per track)
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const [syncingScroll, setSyncingScroll] = React.useState(false);
// Synchronize vertical scroll between controls and waveforms
const handleWaveformScroll = React.useCallback(() => {
if (waveformScrollRef.current && controlsScrollRef.current) {
@@ -68,6 +73,61 @@ export function TrackList({
}
}, []);
// Synchronize horizontal scroll across all tracks (waveforms and automation lanes)
const handleWaveformHScroll = React.useCallback((trackId: string) => {
if (syncingScroll) return;
setSyncingScroll(true);
const sourceEl = waveformHScrollRefs.current.get(trackId);
if (!sourceEl) {
setSyncingScroll(false);
return;
}
const scrollLeft = sourceEl.scrollLeft;
// Sync all waveforms
waveformHScrollRefs.current.forEach((el, id) => {
if (id !== trackId) {
el.scrollLeft = scrollLeft;
}
});
// Sync all automation lanes
automationHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
setSyncingScroll(false);
}, [syncingScroll]);
const handleAutomationHScroll = React.useCallback((trackId: string) => {
if (syncingScroll) return;
setSyncingScroll(true);
const sourceEl = automationHScrollRefs.current.get(trackId);
if (!sourceEl) {
setSyncingScroll(false);
return;
}
const scrollLeft = sourceEl.scrollLeft;
// Sync all waveforms
waveformHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
// Sync all automation lanes
automationHScrollRefs.current.forEach((el, id) => {
if (id !== trackId) {
el.scrollLeft = scrollLeft;
}
});
setSyncingScroll(false);
}, [syncingScroll]);
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
if (onImportTrack) {
onImportTrack(buffer, name);
@@ -108,7 +168,7 @@ export function TrackList({
{/* Track List - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
<div ref={controlsScrollRef} className="w-48 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
{tracks.map((track) => (
<React.Fragment key={track.id}>
{/* Track Controls */}
@@ -200,11 +260,11 @@ export function TrackList({
))}
</div>
{/* Right Column: Waveforms (Flexible Width, Shared Horizontal & Vertical Scroll) */}
{/* Right Column: Waveforms (Flexible Width, Vertical Scroll Only) */}
<div
ref={waveformScrollRef}
onScroll={handleWaveformScroll}
className="flex-1 overflow-auto custom-scrollbar"
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<div className="flex flex-col">
{tracks.map((track) => (
@@ -216,8 +276,29 @@ export function TrackList({
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
}}
>
{/* Waveform - Takes remaining space */}
{/* Waveform - Takes remaining space, horizontally scrollable */}
<div className="flex-1 min-h-0 relative">
{/* Upload hint for empty tracks - stays fixed as overlay */}
{!track.audioBuffer && !track.collapsed && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-sm text-muted-foreground pointer-events-none z-10">
<Upload className="h-6 w-6 mb-2 opacity-50" />
<p>Click waveform area to load audio</p>
<p className="text-xs opacity-75 mt-1">or drag & drop</p>
</div>
)}
<div
ref={(el) => {
if (el) waveformHScrollRefs.current.set(track.id, el);
}}
onScroll={() => handleWaveformHScroll(track.id)}
className="w-full h-full overflow-x-auto custom-scrollbar"
>
<div
className="h-full"
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
}}
>
<Track
track={track}
zoom={zoom}
@@ -302,6 +383,8 @@ export function TrackList({
isPlaying={isPlaying}
renderWaveformOnly={true}
/>
</div>
</div>
</div>
{/* Automation Bar - Collapsible - Fixed height when expanded */}
@@ -328,54 +411,167 @@ export function TrackList({
}
});
// Get parameters that have automation lanes with points
const automatedParams = track.automation.lanes
.filter(lane => lane.points.length > 0)
.map(lane => {
const param = availableParameters.find(p => p.id === lane.parameterId);
return param ? param.name : lane.parameterName;
});
const modes = ['read', 'write', 'touch', 'latch'] as const;
const MODE_LABELS = { read: 'R', write: 'W', touch: 'T', latch: 'L' };
const MODE_COLORS = {
read: 'text-muted-foreground',
write: 'text-red-500',
touch: 'text-yellow-500',
latch: 'text-orange-500',
};
const currentModeIndex = modes.indexOf(currentLane?.mode || 'read');
return (
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm">
<AutomationHeader
parameterName={currentLane?.parameterName || 'Volume'}
visible={currentLane?.visible ?? true}
mode={currentLane?.mode || 'read'}
color={currentLane?.color}
availableParameters={availableParameters}
selectedParameterId={selectedParam}
onParameterChange={(parameterId) => {
onUpdateTrack(track.id, {
automation: { ...track.automation, selectedParameterId: parameterId },
});
}}
onToggleVisible={() => {
if (currentLane) {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, visible: !l.visible } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
onModeChange={(mode) => {
if (currentLane) {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, mode } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
onHeightChange={(delta) => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height + delta));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
/>
{/* Automation Header - Single Bar */}
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30">
<span className="text-xs font-medium flex-shrink-0">Automation</span>
{/* Automation Lane Content - Collapsible */}
{/* Color indicator */}
{currentLane?.color && (
<div
className="w-1 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: currentLane.color }}
/>
)}
{/* Parameter labels - always visible */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
{automatedParams.map((paramName, index) => (
<span
key={index}
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0 bg-primary/10 text-primary border border-primary/20"
>
{paramName}
</span>
))}
</div>
{/* Controls - only visible when expanded */}
{track.automationExpanded && (
<>
{/* Parameter selector */}
{availableParameters && availableParameters.length > 1 && (
<select
value={selectedParam}
onChange={(e) => onUpdateTrack(track.id, {
automation: { ...track.automation, selectedParameterId: e.target.value },
})}
className="text-xs font-medium text-foreground w-auto min-w-[120px] max-w-[200px] bg-background/50 border border-border/30 rounded px-1.5 py-0.5 hover:bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary flex-shrink-0"
>
{availableParameters.map((param) => (
<option key={param.id} value={param.id}>
{param.name}
</option>
))}
</select>
)}
{/* Automation mode button */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const nextIndex = (currentModeIndex + 1) % modes.length;
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, mode: modes[nextIndex] } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title={`Automation mode: ${currentLane?.mode || 'read'} (click to cycle)`}
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[currentLane?.mode || 'read'])}
>
{MODE_LABELS[currentLane?.mode || 'read']}
</Button>
{/* Height controls */}
<div className="flex flex-col gap-0 flex-shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height + 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Increase lane height"
className="h-3 w-4 p-0"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height - 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Decrease lane height"
className="h-3 w-4 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
</>
)}
{/* Show/hide toggle - Positioned absolutely on the right */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
onUpdateTrack(track.id, { automationExpanded: !track.automationExpanded });
}}
title={track.automationExpanded ? 'Hide automation controls' : 'Show automation controls'}
className="absolute right-2 h-5 w-5 flex-shrink-0"
>
{track.automationExpanded ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
{/* Automation Lane Content - Shown when expanded */}
{track.automationExpanded && (
<div
ref={(el) => {
if (el) automationHScrollRefs.current.set(track.id, el);
}}
onScroll={() => handleAutomationHScroll(track.id)}
className="overflow-x-auto custom-scrollbar"
>
<div
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
}}
>
{track.automation.lanes
.filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible)
.map((lane) => (
@@ -431,6 +627,9 @@ export function TrackList({
}}
/>
))}
</div>
</div>
)}
</div>
);
})()}
@@ -439,7 +638,7 @@ export function TrackList({
{!track.collapsed && (
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm border-b border-border">
{/* Effects Header - Collapsible */}
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-b border-border/30 overflow-x-auto">
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-t border-b border-border/30 overflow-x-auto">
<span className="text-xs font-medium flex-shrink-0">Effects</span>
{/* Effect name labels */}
@@ -477,7 +676,7 @@ export function TrackList({
</Button>
</div>
{/* Effects Content - Collapsible, no inner container */}
{/* Effects Content - Collapsible, horizontally scrollable */}
{track.effectsExpanded && (
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 border-t border-border">
<div className="flex h-full gap-3 p-3">