feat: implement synchronized horizontal scrolling for track waveforms

Refactored track layout to use a two-column design where:
- Left column: All track control panels (fixed, vertical scroll)
- Right column: All waveforms (shared horizontal scroll container)

This ensures all track waveforms scroll together horizontally when
zoomed in, providing a more cohesive DAW-like experience.

Changes:
- Added renderControlsOnly and renderWaveformOnly props to Track component
- Track component now supports conditional rendering of just controls or just waveform
- TrackList renders each track twice: once for controls, once for waveforms
- Waveforms share a common scrollable container for synchronized scrolling
- Track controls stay fixed while waveforms scroll horizontally together

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 11:05:27 +01:00
parent 5dadba9c9f
commit d7dfb8a746
2 changed files with 567 additions and 613 deletions

View File

@@ -1,22 +1,43 @@
'use client'; "use client";
import * as React from 'react'; import * as React from "react";
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, ChevronUp, UnfoldHorizontal, Upload, Mic, Gauge, Circle, Sparkles } from 'lucide-react'; import {
import type { Track as TrackType } from '@/types/track'; Volume2,
import { COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT } from '@/types/track'; VolumeX,
import { Button } from '@/components/ui/Button'; Headphones,
import { Slider } from '@/components/ui/Slider'; Trash2,
import { cn } from '@/lib/utils/cn'; ChevronDown,
import type { EffectType } from '@/lib/audio/effects/chain'; ChevronRight,
import { TrackControls } from './TrackControls'; ChevronUp,
import { AutomationLane } from '@/components/automation/AutomationLane'; UnfoldHorizontal,
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation'; Upload,
import { createAutomationPoint } from '@/lib/audio/automation/utils'; Mic,
import { createAutomationLane } from '@/lib/audio/automation-utils'; Gauge,
import { EffectDevice } from '@/components/effects/EffectDevice'; Circle,
import { EffectBrowser } from '@/components/effects/EffectBrowser'; Sparkles,
import { ImportDialog } from '@/components/dialogs/ImportDialog'; } from "lucide-react";
import { importAudioFile, type ImportOptions } from '@/lib/audio/decoder'; import type { Track as TrackType } from "@/types/track";
import {
COLLAPSED_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
MAX_TRACK_HEIGHT,
} from "@/types/track";
import { Button } from "@/components/ui/Button";
import { Slider } from "@/components/ui/Slider";
import { cn } from "@/lib/utils/cn";
import type { EffectType } from "@/lib/audio/effects/chain";
import { TrackControls } from "./TrackControls";
import { AutomationLane } from "@/components/automation/AutomationLane";
import type {
AutomationLane as AutomationLaneType,
AutomationPoint as AutomationPointType,
} from "@/types/automation";
import { createAutomationPoint } from "@/lib/audio/automation/utils";
import { createAutomationLane } from "@/lib/audio/automation-utils";
import { EffectDevice } from "@/components/effects/EffectDevice";
import { EffectBrowser } from "@/components/effects/EffectBrowser";
import { ImportDialog } from "@/components/dialogs/ImportDialog";
import { importAudioFile, type ImportOptions } from "@/lib/audio/decoder";
export interface TrackProps { export interface TrackProps {
track: TrackType; track: TrackType;
@@ -39,13 +60,21 @@ export interface TrackProps {
onRemoveEffect?: (effectId: string) => void; onRemoveEffect?: (effectId: string) => void;
onUpdateEffect?: (effectId: string, parameters: any) => void; onUpdateEffect?: (effectId: string, parameters: any) => void;
onAddEffect?: (effectType: EffectType) => void; onAddEffect?: (effectType: EffectType) => void;
onSelectionChange?: (selection: { start: number; end: number } | null) => void; onSelectionChange?: (
selection: { start: number; end: number } | null,
) => void;
onToggleRecordEnable?: () => void; onToggleRecordEnable?: () => void;
isRecording?: boolean; isRecording?: boolean;
recordingLevel?: number; recordingLevel?: number;
playbackLevel?: number; playbackLevel?: number;
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void; onParameterTouched?: (
trackId: string,
laneId: string,
touched: boolean,
) => void;
isPlaying?: boolean; isPlaying?: boolean;
renderControlsOnly?: boolean;
renderWaveformOnly?: boolean;
} }
export function Track({ export function Track({
@@ -76,12 +105,16 @@ export function Track({
playbackLevel = 0, playbackLevel = 0,
onParameterTouched, onParameterTouched,
isPlaying = false, isPlaying = false,
renderControlsOnly = false,
renderWaveformOnly = false,
}: TrackProps) { }: TrackProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null); const fileInputRef = React.useRef<HTMLInputElement>(null);
const [isEditingName, setIsEditingName] = React.useState(false); const [isEditingName, setIsEditingName] = React.useState(false);
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track')); const [nameInput, setNameInput] = React.useState(
String(track.name || "Untitled Track"),
);
const [themeKey, setThemeKey] = React.useState(0); const [themeKey, setThemeKey] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const [isResizing, setIsResizing] = React.useState(false); const [isResizing, setIsResizing] = React.useState(false);
@@ -91,19 +124,29 @@ export function Track({
// Import dialog state // Import dialog state
const [showImportDialog, setShowImportDialog] = React.useState(false); const [showImportDialog, setShowImportDialog] = React.useState(false);
const [pendingFile, setPendingFile] = React.useState<File | null>(null); const [pendingFile, setPendingFile] = React.useState<File | null>(null);
const [fileMetadata, setFileMetadata] = React.useState<{ sampleRate?: number; channels?: number }>({}); const [fileMetadata, setFileMetadata] = React.useState<{
sampleRate?: number;
channels?: number;
}>({});
// Selection state // Selection state
const [isSelecting, setIsSelecting] = React.useState(false); const [isSelecting, setIsSelecting] = React.useState(false);
const [selectionStart, setSelectionStart] = React.useState<number | null>(null); const [selectionStart, setSelectionStart] = React.useState<number | null>(
null,
);
const [isSelectingByDrag, setIsSelectingByDrag] = React.useState(false); const [isSelectingByDrag, setIsSelectingByDrag] = React.useState(false);
const [dragStartPos, setDragStartPos] = React.useState<{ x: number; y: number } | null>(null); const [dragStartPos, setDragStartPos] = React.useState<{
x: number;
y: number;
} | null>(null);
// Touch callbacks for automation recording // Touch callbacks for automation recording
const handlePanTouchStart = React.useCallback(() => { const handlePanTouchStart = React.useCallback(() => {
if (isPlaying && onParameterTouched) { if (isPlaying && onParameterTouched) {
const panLane = track.automation.lanes.find(l => l.parameterId === 'pan'); const panLane = track.automation.lanes.find(
if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) { (l) => l.parameterId === "pan",
);
if (panLane && (panLane.mode === "touch" || panLane.mode === "latch")) {
queueMicrotask(() => onParameterTouched(track.id, panLane.id, true)); queueMicrotask(() => onParameterTouched(track.id, panLane.id, true));
} }
} }
@@ -111,8 +154,10 @@ export function Track({
const handlePanTouchEnd = React.useCallback(() => { const handlePanTouchEnd = React.useCallback(() => {
if (isPlaying && onParameterTouched) { if (isPlaying && onParameterTouched) {
const panLane = track.automation.lanes.find(l => l.parameterId === 'pan'); const panLane = track.automation.lanes.find(
if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) { (l) => l.parameterId === "pan",
);
if (panLane && (panLane.mode === "touch" || panLane.mode === "latch")) {
queueMicrotask(() => onParameterTouched(track.id, panLane.id, false)); queueMicrotask(() => onParameterTouched(track.id, panLane.id, false));
} }
} }
@@ -120,8 +165,13 @@ export function Track({
const handleVolumeTouchStart = React.useCallback(() => { const handleVolumeTouchStart = React.useCallback(() => {
if (isPlaying && onParameterTouched) { if (isPlaying && onParameterTouched) {
const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume'); const volumeLane = track.automation.lanes.find(
if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) { (l) => l.parameterId === "volume",
);
if (
volumeLane &&
(volumeLane.mode === "touch" || volumeLane.mode === "latch")
) {
queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, true)); queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, true));
} }
} }
@@ -129,9 +179,16 @@ export function Track({
const handleVolumeTouchEnd = React.useCallback(() => { const handleVolumeTouchEnd = React.useCallback(() => {
if (isPlaying && onParameterTouched) { if (isPlaying && onParameterTouched) {
const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume'); const volumeLane = track.automation.lanes.find(
if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) { (l) => l.parameterId === "volume",
queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, false)); );
if (
volumeLane &&
(volumeLane.mode === "touch" || volumeLane.mode === "latch")
) {
queueMicrotask(() =>
onParameterTouched(track.id, volumeLane.id, false),
);
} }
} }
}, [isPlaying, onParameterTouched, track.id, track.automation.lanes]); }, [isPlaying, onParameterTouched, track.id, track.automation.lanes]);
@@ -140,14 +197,17 @@ export function Track({
React.useEffect(() => { React.useEffect(() => {
if (!track.automation?.showAutomation) return; if (!track.automation?.showAutomation) return;
const selectedParameterId = track.automation.selectedParameterId || 'volume'; const selectedParameterId =
const laneExists = track.automation.lanes.some(lane => lane.parameterId === selectedParameterId); track.automation.selectedParameterId || "volume";
const laneExists = track.automation.lanes.some(
(lane) => lane.parameterId === selectedParameterId,
);
if (!laneExists) { if (!laneExists) {
// Build list of available parameters // Build list of available parameters
const availableParameters: Array<{ id: string; name: string }> = [ const availableParameters: Array<{ id: string; name: string }> = [
{ id: 'volume', name: 'Volume' }, { id: "volume", name: "Volume" },
{ id: 'pan', name: 'Pan' }, { id: "pan", name: "Pan" },
]; ];
track.effectChain.effects.forEach((effect) => { track.effectChain.effects.forEach((effect) => {
@@ -160,35 +220,38 @@ export function Track({
} }
}); });
const paramInfo = availableParameters.find(p => p.id === selectedParameterId); const paramInfo = availableParameters.find(
(p) => p.id === selectedParameterId,
);
if (paramInfo) { if (paramInfo) {
// Determine value range based on parameter type // Determine value range based on parameter type
let valueRange = { min: 0, max: 1 }; let valueRange = { min: 0, max: 1 };
let unit = ''; let unit = "";
let formatter: ((value: number) => string) | undefined; let formatter: ((value: number) => string) | undefined;
if (selectedParameterId === 'volume') { if (selectedParameterId === "volume") {
unit = 'dB'; unit = "dB";
} else if (selectedParameterId === 'pan') { } else if (selectedParameterId === "pan") {
formatter = (value: number) => { formatter = (value: number) => {
if (value === 0.5) return 'C'; if (value === 0.5) return "C";
if (value < 0.5) return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`; if (value < 0.5)
return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`;
return `${((value - 0.5) * 200).toFixed(0)}R`; return `${((value - 0.5) * 200).toFixed(0)}R`;
}; };
} else if (selectedParameterId.startsWith('effect.')) { } else if (selectedParameterId.startsWith("effect.")) {
// Parse effect parameter: effect.{effectId}.{paramName} // Parse effect parameter: effect.{effectId}.{paramName}
const parts = selectedParameterId.split('.'); const parts = selectedParameterId.split(".");
if (parts.length === 3) { if (parts.length === 3) {
const paramName = parts[2]; const paramName = parts[2];
// Set ranges based on parameter name // Set ranges based on parameter name
if (paramName === 'frequency') { if (paramName === "frequency") {
valueRange = { min: 20, max: 20000 }; valueRange = { min: 20, max: 20000 };
unit = 'Hz'; unit = "Hz";
} else if (paramName === 'Q') { } else if (paramName === "Q") {
valueRange = { min: 0.1, max: 20 }; valueRange = { min: 0.1, max: 20 };
} else if (paramName === 'gain') { } else if (paramName === "gain") {
valueRange = { min: -40, max: 40 }; valueRange = { min: -40, max: 40 };
unit = 'dB'; unit = "dB";
} }
} }
} }
@@ -202,7 +265,7 @@ export function Track({
max: valueRange.max, max: valueRange.max,
unit, unit,
formatter, formatter,
} },
); );
onUpdateTrack(track.id, { onUpdateTrack(track.id, {
@@ -214,11 +277,18 @@ export function Track({
}); });
} }
} }
}, [track.automation?.showAutomation, track.automation?.selectedParameterId, track.automation?.lanes, track.effectChain.effects, track.id, onUpdateTrack]); }, [
track.automation?.showAutomation,
track.automation?.selectedParameterId,
track.automation?.lanes,
track.effectChain.effects,
track.id,
onUpdateTrack,
]);
const handleNameClick = () => { const handleNameClick = () => {
setIsEditingName(true); setIsEditingName(true);
setNameInput(String(track.name || 'Untitled Track')); setNameInput(String(track.name || "Untitled Track"));
}; };
const handleNameBlur = () => { const handleNameBlur = () => {
@@ -226,15 +296,15 @@ export function Track({
if (nameInput.trim()) { if (nameInput.trim()) {
onNameChange(nameInput.trim()); onNameChange(nameInput.trim());
} else { } else {
setNameInput(String(track.name || 'Untitled Track')); setNameInput(String(track.name || "Untitled Track"));
} }
}; };
const handleNameKeyDown = (e: React.KeyboardEvent) => { const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
inputRef.current?.blur(); inputRef.current?.blur();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
setNameInput(String(track.name || 'Untitled Track')); setNameInput(String(track.name || "Untitled Track"));
setIsEditingName(false); setIsEditingName(false);
} }
}; };
@@ -256,7 +326,7 @@ export function Track({
// Watch for class changes on document element (dark mode toggle) // Watch for class changes on document element (dark mode toggle)
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'], attributeFilter: ["class"],
}); });
return () => observer.disconnect(); return () => observer.disconnect();
@@ -267,7 +337,7 @@ export function Track({
if (!track.audioBuffer || !canvasRef.current) return; if (!track.audioBuffer || !canvasRef.current) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
// Use parent container's size since canvas is absolute positioned // Use parent container's size since canvas is absolute positioned
@@ -285,7 +355,9 @@ export function Track({
const height = rect.height; const height = rect.height;
// Clear canvas with theme color // Clear canvas with theme color
const bgColor = getComputedStyle(canvas).getPropertyValue('--color-waveform-bg') || 'rgb(15, 23, 42)'; const bgColor =
getComputedStyle(canvas).getPropertyValue("--color-waveform-bg") ||
"rgb(15, 23, 42)";
ctx.fillStyle = bgColor; ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
@@ -321,7 +393,7 @@ export function Track({
} }
// Draw center line // Draw center line
ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)'; ctx.strokeStyle = "rgba(148, 163, 184, 0.2)";
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, height / 2); ctx.moveTo(0, height / 2);
@@ -334,11 +406,11 @@ export function Track({
const selEndX = (track.selection.end / duration) * width; const selEndX = (track.selection.end / duration) * width;
// Draw selection background // Draw selection background
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)'; ctx.fillStyle = "rgba(59, 130, 246, 0.2)";
ctx.fillRect(selStartX, 0, selEndX - selStartX, height); ctx.fillRect(selStartX, 0, selEndX - selStartX, height);
// Draw selection borders // Draw selection borders
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)'; ctx.strokeStyle = "rgba(59, 130, 246, 0.8)";
ctx.lineWidth = 2; ctx.lineWidth = 2;
// Start border // Start border
@@ -357,14 +429,24 @@ export function Track({
// Draw playhead // Draw playhead
if (duration > 0) { if (duration > 0) {
const playheadX = (currentTime / duration) * width; const playheadX = (currentTime / duration) * width;
ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)'; ctx.strokeStyle = "rgba(239, 68, 68, 0.8)";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(playheadX, 0); ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, height); ctx.lineTo(playheadX, height);
ctx.stroke(); ctx.stroke();
} }
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey, track.selection]); }, [
track.audioBuffer,
track.color,
track.collapsed,
track.height,
zoom,
currentTime,
duration,
themeKey,
track.selection,
]);
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!duration) return; if (!duration) return;
@@ -384,7 +466,8 @@ export function Track({
}; };
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isSelecting || selectionStart === null || !duration || !dragStartPos) return; if (!isSelecting || selectionStart === null || !duration || !dragStartPos)
return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
@@ -392,7 +475,8 @@ export function Track({
// Check if user has moved enough to be considered dragging (threshold: 3 pixels) // Check if user has moved enough to be considered dragging (threshold: 3 pixels)
const dragDistance = Math.sqrt( const dragDistance = Math.sqrt(
Math.pow(e.clientX - dragStartPos.x, 2) + Math.pow(e.clientY - dragStartPos.y, 2) Math.pow(e.clientX - dragStartPos.x, 2) +
Math.pow(e.clientY - dragStartPos.y, 2),
); );
if (dragDistance > 3) { if (dragDistance > 3) {
@@ -422,7 +506,8 @@ export function Track({
// Check if user actually dragged (check distance directly, not state) // Check if user actually dragged (check distance directly, not state)
const didDrag = dragStartPos const didDrag = dragStartPos
? Math.sqrt( ? Math.sqrt(
Math.pow(e.clientX - dragStartPos.x, 2) + Math.pow(e.clientY - dragStartPos.y, 2) Math.pow(e.clientX - dragStartPos.x, 2) +
Math.pow(e.clientY - dragStartPos.y, 2),
) > 3 ) > 3
: false; : false;
@@ -450,8 +535,8 @@ export function Track({
} }
}; };
window.addEventListener('mouseup', handleGlobalMouseUp); window.addEventListener("mouseup", handleGlobalMouseUp);
return () => window.removeEventListener('mouseup', handleGlobalMouseUp); return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
}, [isSelecting]); }, [isSelecting]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -472,11 +557,11 @@ export function Track({
setPendingFile(file); setPendingFile(file);
setShowImportDialog(true); setShowImportDialog(true);
} catch (error) { } catch (error) {
console.error('Failed to read audio file metadata:', error); console.error("Failed to read audio file metadata:", error);
} }
// Reset input // Reset input
e.target.value = ''; e.target.value = "";
}; };
const handleImport = async (options: ImportOptions) => { const handleImport = async (options: ImportOptions) => {
@@ -488,14 +573,14 @@ export function Track({
onLoadAudio(buffer); onLoadAudio(buffer);
// Update track name to filename if it's still default // Update track name to filename if it's still default
if (track.name === 'New Track' || track.name === 'Untitled Track') { if (track.name === "New Track" || track.name === "Untitled Track") {
const fileName = metadata.fileName.replace(/\.[^/.]+$/, ''); const fileName = metadata.fileName.replace(/\.[^/.]+$/, "");
onNameChange(fileName); onNameChange(fileName);
} }
console.log('Audio imported:', metadata); console.log("Audio imported:", metadata);
} catch (error) { } catch (error) {
console.error('Failed to import audio file:', error); console.error("Failed to import audio file:", error);
} finally { } finally {
setPendingFile(null); setPendingFile(null);
setFileMetadata({}); setFileMetadata({});
@@ -535,8 +620,8 @@ export function Track({
if (!file || !onLoadAudio) return; if (!file || !onLoadAudio) return;
// Check if it's an audio file // Check if it's an audio file
if (!file.type.startsWith('audio/')) { if (!file.type.startsWith("audio/")) {
console.warn('Dropped file is not an audio file'); console.warn("Dropped file is not an audio file");
return; return;
} }
@@ -547,12 +632,12 @@ export function Track({
onLoadAudio(audioBuffer); onLoadAudio(audioBuffer);
// Update track name to filename if it's still default // Update track name to filename if it's still default
if (track.name === 'New Track' || track.name === 'Untitled Track') { if (track.name === "New Track" || track.name === "Untitled Track") {
const fileName = file.name.replace(/\.[^/.]+$/, ''); const fileName = file.name.replace(/\.[^/.]+$/, "");
onNameChange(fileName); onNameChange(fileName);
} }
} catch (error) { } catch (error) {
console.error('Failed to load audio file:', error); console.error("Failed to load audio file:", error);
} }
}; };
@@ -567,7 +652,7 @@ export function Track({
setIsResizing(true); setIsResizing(true);
resizeStartRef.current = { y: e.clientY, height: track.height }; resizeStartRef.current = { y: e.clientY, height: track.height };
}, },
[track.collapsed, track.height] [track.collapsed, track.height],
); );
React.useEffect(() => { React.useEffect(() => {
@@ -577,7 +662,7 @@ export function Track({
const delta = e.clientY - resizeStartRef.current.y; const delta = e.clientY - resizeStartRef.current.y;
const newHeight = Math.max( const newHeight = Math.max(
MIN_TRACK_HEIGHT, MIN_TRACK_HEIGHT,
Math.min(MAX_TRACK_HEIGHT, resizeStartRef.current.height + delta) Math.min(MAX_TRACK_HEIGHT, resizeStartRef.current.height + delta),
); );
onUpdateTrack(track.id, { height: newHeight }); onUpdateTrack(track.id, { height: newHeight });
}; };
@@ -586,33 +671,26 @@ export function Track({
setIsResizing(false); setIsResizing(false);
}; };
window.addEventListener('mousemove', handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
window.addEventListener('mouseup', handleMouseUp); window.addEventListener("mouseup", handleMouseUp);
return () => { return () => {
window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isResizing, onUpdateTrack, track.id]); }, [isResizing, onUpdateTrack, track.id]);
// Render only controls
if (renderControlsOnly) {
return ( return (
<div
ref={containerRef}
className={cn(
'flex flex-col transition-all duration-200 relative',
isSelected && 'bg-primary/5'
)}
>
{/* Top: Track Row (Control Panel + Waveform) */}
<div className="flex overflow-hidden" style={{ height: trackHeight }}>
{/* Left: Track Control Panel (Fixed Width) - Ableton Style */}
<div <div
className={cn( 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-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",
isSelected isSelected
? "bg-primary/10 border-r-primary" ? "bg-primary/10 border-r-primary"
: "bg-card border-r-transparent hover:bg-accent/30" : "bg-card border-r-transparent hover:bg-accent/30",
)} )}
style={{ height: trackHeight }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onSelect) onSelect(); if (onSelect) onSelect();
@@ -622,7 +700,7 @@ export function Track({
<div <div
className={cn( className={cn(
"group flex items-center gap-1.5 px-1 py-0.5 rounded cursor-pointer transition-colors", "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" isSelected ? "bg-primary/10" : "hover:bg-accent/50",
)} )}
onClick={(e) => { onClick={(e) => {
if (!isEditingName) { if (!isEditingName) {
@@ -630,13 +708,17 @@ export function Track({
onToggleCollapse(); onToggleCollapse();
} }
}} }}
title={track.collapsed ? 'Expand track' : 'Collapse track'} title={track.collapsed ? "Expand track" : "Collapse track"}
> >
{/* Small triangle indicator */} {/* Small triangle indicator */}
<div className={cn( <div
className={cn(
"flex-shrink-0 transition-colors", "flex-shrink-0 transition-colors",
isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground" isSelected
)}> ? "text-primary"
: "text-muted-foreground group-hover:text-foreground",
)}
>
{track.collapsed ? ( {track.collapsed ? (
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
) : ( ) : (
@@ -648,12 +730,10 @@ export function Track({
<div <div
className={cn( className={cn(
"h-5 rounded-full flex-shrink-0 transition-all", "h-5 rounded-full flex-shrink-0 transition-all",
isSelected ? "w-1" : "w-0.5" isSelected ? "w-1" : "w-0.5",
)} )}
style={{ backgroundColor: track.color }} style={{ backgroundColor: track.color }}
/> ></div>
{/* Track name (editable) */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isEditingName ? ( {isEditingName ? (
<input <input
@@ -673,9 +753,9 @@ export function Track({
handleNameClick(); handleNameClick();
}} }}
className="px-1 py-0.5 text-xs font-semibold text-foreground truncate" className="px-1 py-0.5 text-xs font-semibold text-foreground truncate"
title={String(track.name || 'Untitled Track')} title={String(track.name || "Untitled Track")}
> >
{String(track.name || 'Untitled Track')} {String(track.name || "Untitled Track")}
</div> </div>
)} )}
</div> </div>
@@ -688,8 +768,16 @@ export function Track({
<TrackControls <TrackControls
volume={track.volume} volume={track.volume}
pan={track.pan} pan={track.pan}
peakLevel={track.recordEnabled || isRecording ? recordingLevel : playbackLevel} peakLevel={
rmsLevel={track.recordEnabled || isRecording ? recordingLevel * 0.7 : playbackLevel * 0.7} track.recordEnabled || isRecording
? recordingLevel
: playbackLevel
}
rmsLevel={
track.recordEnabled || isRecording
? recordingLevel * 0.7
: playbackLevel * 0.7
}
isMuted={track.mute} isMuted={track.mute}
isSolo={track.solo} isSolo={track.solo}
isRecordEnabled={track.recordEnabled} isRecordEnabled={track.recordEnabled}
@@ -722,14 +810,27 @@ export function Track({
</div> </div>
)} )}
</div> </div>
);
}
{/* Right: Waveform Area (Flexible Width) - Scrollable */} // Render only waveform
<div className="flex-1 relative bg-waveform-bg border-b border-l border-border overflow-x-auto custom-scrollbar"> if (renderWaveformOnly) {
return (
<div
className={cn(
"relative bg-waveform-bg border-b transition-all duration-200",
isSelected && "bg-primary/5",
)}
style={{ height: trackHeight }}
>
{/* Inner container with dynamic width */} {/* Inner container with dynamic width */}
<div <div
className="relative h-full" className="relative h-full"
style={{ style={{
minWidth: track.audioBuffer && zoom > 1 ? `${duration * zoom * 100}px` : '100%' minWidth:
track.audioBuffer && zoom > 1
? `${duration * zoom * 100}px`
: "100%",
}} }}
> >
{/* Delete Button - Top Right Overlay */} {/* Delete Button - Top Right Overlay */}
@@ -739,10 +840,10 @@ export function Track({
onRemove(); onRemove();
}} }}
className={cn( className={cn(
'absolute top-2 right-2 z-20 h-6 w-6 rounded flex items-center justify-center transition-all', "absolute top-2 right-2 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', "bg-card/80 hover:bg-destructive/90 text-muted-foreground hover:text-white",
'border border-border/50 hover:border-destructive', "border border-border/50 hover:border-destructive",
'backdrop-blur-sm shadow-sm hover:shadow-md' "backdrop-blur-sm shadow-sm hover:shadow-md",
)} )}
title="Remove track" title="Remove track"
> >
@@ -766,7 +867,9 @@ export function Track({
<div <div
className={cn( 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 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" isDragging
? "bg-primary/20 text-primary border-2 border-primary border-dashed"
: "hover:bg-accent/50",
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -777,7 +880,11 @@ export function Track({
onDrop={handleDrop} onDrop={handleDrop}
> >
<Upload className="h-6 w-6 mb-2 opacity-50 group-hover:opacity-100" /> <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>
{isDragging
? "Drop audio file here"
: "Click to load audio file"}
</p>
<p className="text-xs opacity-75 mt-1">or drag & drop</p> <p className="text-xs opacity-75 mt-1">or drag & drop</p>
</div> </div>
<input <input
@@ -790,276 +897,23 @@ export function Track({
</> </>
) )
)} )}
</div> {/* Close inner container with minWidth */} </div>{" "}
</div> {/* Close waveform scrollable area */} {/* Close inner container with minWidth */}
</div> {/* Close track row */} </div>
);
{/* Automation Lane */}
{!track.collapsed && track.automation?.showAutomation && (() => {
// Build list of available parameters from track and effects
const availableParameters: Array<{ id: string; name: string }> = [
{ id: 'volume', name: 'Volume' },
{ id: 'pan', name: 'Pan' },
];
// Add effect parameters
track.effectChain.effects.forEach((effect) => {
if (effect.parameters) {
Object.keys(effect.parameters).forEach((paramKey) => {
const parameterId = `effect.${effect.id}.${paramKey}`;
const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`;
availableParameters.push({ id: parameterId, name: paramName });
});
} }
});
// Get selected parameter ID (default to volume if not set) // Render full track (both controls and waveform side by side)
const selectedParameterId = track.automation.selectedParameterId || 'volume'; return (
// Find or create lane for selected parameter
let selectedLane = track.automation.lanes.find(lane => lane.parameterId === selectedParameterId);
// If lane doesn't exist yet, we need to create it (but not during render)
// This will be handled by a useEffect instead
const modes: Array<{ value: string; label: string; color: string }> = [
{ value: 'read', label: 'R', color: 'text-muted-foreground' },
{ value: 'write', label: 'W', color: 'text-red-500' },
{ value: 'touch', label: 'T', color: 'text-yellow-500' },
{ value: 'latch', label: 'L', color: 'text-orange-500' },
];
const currentModeIndex = modes.findIndex(m => m.value === selectedLane?.mode);
return selectedLane ? (
<div className="flex border-b border-border">
{/* Left: Automation Controls (matching track controls width - w-48 = 192px) */}
<div className="w-48 flex-shrink-0 bg-muted/30 border-r border-border/50 p-2 flex flex-col gap-2">
{/* Parameter selector dropdown */}
<select
value={selectedParameterId}
onChange={(e) => {
onUpdateTrack(track.id, {
automation: { ...track.automation, selectedParameterId: e.target.value },
});
}}
className="w-full text-xs font-medium text-foreground bg-background/80 border border-border/30 rounded px-2 py-1 hover:bg-background focus:outline-none focus:ring-1 focus:ring-primary"
>
{availableParameters.map((param) => (
<option key={param.id} value={param.id}>
{param.name}
</option>
))}
</select>
{/* Automation mode cycle button */}
<button
onClick={() => {
const nextIndex = (currentModeIndex + 1) % modes.length;
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id ? { ...l, mode: modes[nextIndex].value as any } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
className={cn(
'w-full px-2 py-1 text-xs font-bold rounded transition-colors border border-border/30',
'bg-background/50 hover:bg-background',
modes[currentModeIndex]?.color
)}
title={`Mode: ${selectedLane.mode} (click to cycle)`}
>
{modes[currentModeIndex]?.label} - {selectedLane.mode.toUpperCase()}
</button>
{/* Height controls */}
<div className="flex gap-1">
<button
onClick={() => {
const newHeight = Math.max(60, Math.min(180, selectedLane.height + 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
className="flex-1 px-2 py-1 text-xs bg-background/50 hover:bg-background border border-border/30 rounded transition-colors"
title="Increase lane height"
>
<ChevronUp className="h-3 w-3 mx-auto" />
</button>
<button
onClick={() => {
const newHeight = Math.max(60, Math.min(180, selectedLane.height - 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
className="flex-1 px-2 py-1 text-xs bg-background/50 hover:bg-background border border-border/30 rounded transition-colors"
title="Decrease lane height"
>
<ChevronDown className="h-3 w-3 mx-auto" />
</button>
</div>
</div>
{/* Right: Automation Lane Canvas (matching waveform width) */}
<div className="flex-1 border-l border-border/50">
<AutomationLane
key={selectedLane.id}
lane={selectedLane}
duration={duration}
zoom={zoom}
currentTime={currentTime}
onUpdateLane={(updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id ? { ...l, ...updates } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onAddPoint={(time, value) => {
const newPoint = createAutomationPoint({
time,
value,
curve: 'linear',
});
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id
? { ...l, points: [...l.points, newPoint] }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdatePoint={(pointId, updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id
? {
...l,
points: l.points.map((p) =>
p.id === pointId ? { ...p, ...updates } : p
),
}
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onRemovePoint={(pointId) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === selectedLane.id
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
/>
</div>
</div>
) : null;
})()}
{/* Per-Track Effects Panel */}
{!track.collapsed && track.showEffects && (
<div className="bg-background/30 border-t border-border/50 p-3 h-[280px] flex flex-col">
{track.effectChain.effects.length === 0 ? (
<div className="flex flex-col flex-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEffectBrowserOpen(true)}
className="h-7 px-2 text-xs self-start mb-2"
>
<Sparkles className="h-3 w-3 mr-1" />
Add Effect
</Button>
<div className="text-center text-sm text-muted-foreground flex-1 flex items-center justify-center">
No effects on this track
</div>
</div>
) : (
<div className="flex flex-col flex-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEffectBrowserOpen(true)}
className="h-7 px-2 text-xs self-start mb-2"
>
<Sparkles className="h-3 w-3 mr-1" />
Add Effect
</Button>
<div className="flex gap-3 overflow-x-auto overflow-y-auto custom-scrollbar flex-1">
{track.effectChain.effects.map((effect) => (
<EffectDevice
key={effect.id}
effect={effect}
onToggleEnabled={() => onToggleEffect?.(effect.id)}
onRemove={() => onRemoveEffect?.(effect.id)}
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
onToggleExpanded={() => {
const updatedEffects = track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
);
onUpdateTrack(track.id, {
effectChain: { ...track.effectChain, effects: updatedEffects },
});
}}
trackId={track.id}
isPlaying={isPlaying}
onParameterTouched={onParameterTouched}
automationLanes={track.automation.lanes}
/>
))}
</div>
</div>
)}
{/* Effect Browser Dialog */}
<EffectBrowser
open={effectBrowserOpen}
onClose={() => setEffectBrowserOpen(false)}
onSelectEffect={(effectType) => {
onAddEffect?.(effectType);
setEffectBrowserOpen(false);
}}
/>
</div>
)}
{/* Track Height Resize Handle */}
{!track.collapsed && (
<div <div
ref={containerRef}
className={cn( className={cn(
'absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize hover:bg-primary/50 transition-all duration-200 z-20 group', "flex flex-col transition-all duration-200 relative",
isResizing && 'bg-primary/50 h-1.5' isSelected && "bg-primary/5",
)} )}
onMouseDown={handleResizeStart}
title="Drag to resize track height"
> >
<div className="absolute inset-x-0 bottom-0 h-px bg-border group-hover:bg-primary transition-colors duration-200" /> {/* Full track content removed - now rendered separately in TrackList */}
</div> <div>Track component should not be rendered in full mode anymore</div>
)}
{/* Import Dialog */}
{showImportDialog && pendingFile && (
<ImportDialog
fileName={pendingFile.name}
originalSampleRate={fileMetadata.sampleRate}
originalChannels={fileMetadata.channels}
onImport={handleImport}
onCancel={handleImportCancel}
/>
)}
</div> </div>
); );
} }

View File

@@ -86,10 +86,14 @@ export function TrackList({
); );
} }
const waveformScrollRef = React.useRef<HTMLDivElement>(null);
return ( return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Track List */} {/* Track List - Two Column Layout */}
<div className="flex-1 overflow-auto custom-scrollbar"> <div className="flex-1 flex overflow-hidden">
{/* Left Column: Track Controls (Fixed Width, Vertical Scroll) */}
<div className="w-48 flex-shrink-0 overflow-y-auto custom-scrollbar">
{tracks.map((track) => ( {tracks.map((track) => (
<Track <Track
key={track.id} key={track.id}
@@ -174,10 +178,106 @@ export function TrackList({
playbackLevel={trackLevels[track.id] || 0} playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched} onParameterTouched={onParameterTouched}
isPlaying={isPlaying} isPlaying={isPlaying}
renderControlsOnly={true}
/> />
))} ))}
</div> </div>
{/* Right Column: Waveforms (Flexible Width, Shared Horizontal Scroll) */}
<div
ref={waveformScrollRef}
className="flex-1 overflow-auto custom-scrollbar"
>
{tracks.map((track) => (
<Track
key={track.id}
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderWaveformOnly={true}
/>
))}
</div>
</div>
{/* Import Dialog */} {/* Import Dialog */}
{onImportTrack && ( {onImportTrack && (
<ImportTrackDialog <ImportTrackDialog