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

@@ -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">