diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index d363f72..132eb2b 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -1,22 +1,43 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, ChevronUp, UnfoldHorizontal, Upload, Mic, Gauge, Circle, Sparkles } from 'lucide-react'; -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'; +import * as React from "react"; +import { + Volume2, + VolumeX, + Headphones, + Trash2, + ChevronDown, + ChevronRight, + ChevronUp, + UnfoldHorizontal, + Upload, + Mic, + Gauge, + Circle, + Sparkles, +} from "lucide-react"; +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 { track: TrackType; @@ -39,13 +60,21 @@ export interface TrackProps { onRemoveEffect?: (effectId: string) => void; onUpdateEffect?: (effectId: string, parameters: any) => void; onAddEffect?: (effectType: EffectType) => void; - onSelectionChange?: (selection: { start: number; end: number } | null) => void; + onSelectionChange?: ( + selection: { start: number; end: number } | null, + ) => void; onToggleRecordEnable?: () => void; isRecording?: boolean; recordingLevel?: number; playbackLevel?: number; - onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void; + onParameterTouched?: ( + trackId: string, + laneId: string, + touched: boolean, + ) => void; isPlaying?: boolean; + renderControlsOnly?: boolean; + renderWaveformOnly?: boolean; } export function Track({ @@ -76,12 +105,16 @@ export function Track({ playbackLevel = 0, onParameterTouched, isPlaying = false, + renderControlsOnly = false, + renderWaveformOnly = false, }: TrackProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); const fileInputRef = React.useRef(null); 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 inputRef = React.useRef(null); const [isResizing, setIsResizing] = React.useState(false); @@ -91,19 +124,29 @@ export function Track({ // Import dialog state const [showImportDialog, setShowImportDialog] = React.useState(false); const [pendingFile, setPendingFile] = React.useState(null); - const [fileMetadata, setFileMetadata] = React.useState<{ sampleRate?: number; channels?: number }>({}); + const [fileMetadata, setFileMetadata] = React.useState<{ + sampleRate?: number; + channels?: number; + }>({}); // Selection state const [isSelecting, setIsSelecting] = React.useState(false); - const [selectionStart, setSelectionStart] = React.useState(null); + const [selectionStart, setSelectionStart] = React.useState( + null, + ); 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 const handlePanTouchStart = React.useCallback(() => { if (isPlaying && onParameterTouched) { - const panLane = track.automation.lanes.find(l => l.parameterId === 'pan'); - if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) { + const panLane = track.automation.lanes.find( + (l) => l.parameterId === "pan", + ); + if (panLane && (panLane.mode === "touch" || panLane.mode === "latch")) { queueMicrotask(() => onParameterTouched(track.id, panLane.id, true)); } } @@ -111,8 +154,10 @@ export function Track({ const handlePanTouchEnd = React.useCallback(() => { if (isPlaying && onParameterTouched) { - const panLane = track.automation.lanes.find(l => l.parameterId === 'pan'); - if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) { + const panLane = track.automation.lanes.find( + (l) => l.parameterId === "pan", + ); + if (panLane && (panLane.mode === "touch" || panLane.mode === "latch")) { queueMicrotask(() => onParameterTouched(track.id, panLane.id, false)); } } @@ -120,8 +165,13 @@ export function Track({ const handleVolumeTouchStart = React.useCallback(() => { if (isPlaying && onParameterTouched) { - const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume'); - if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) { + const volumeLane = track.automation.lanes.find( + (l) => l.parameterId === "volume", + ); + if ( + volumeLane && + (volumeLane.mode === "touch" || volumeLane.mode === "latch") + ) { queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, true)); } } @@ -129,9 +179,16 @@ export function Track({ const handleVolumeTouchEnd = React.useCallback(() => { if (isPlaying && onParameterTouched) { - const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume'); - if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) { - queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, false)); + const volumeLane = track.automation.lanes.find( + (l) => l.parameterId === "volume", + ); + if ( + volumeLane && + (volumeLane.mode === "touch" || volumeLane.mode === "latch") + ) { + queueMicrotask(() => + onParameterTouched(track.id, volumeLane.id, false), + ); } } }, [isPlaying, onParameterTouched, track.id, track.automation.lanes]); @@ -140,14 +197,17 @@ export function Track({ React.useEffect(() => { if (!track.automation?.showAutomation) return; - const selectedParameterId = track.automation.selectedParameterId || 'volume'; - const laneExists = track.automation.lanes.some(lane => lane.parameterId === selectedParameterId); + const selectedParameterId = + track.automation.selectedParameterId || "volume"; + const laneExists = track.automation.lanes.some( + (lane) => lane.parameterId === selectedParameterId, + ); if (!laneExists) { // Build list of available parameters const availableParameters: Array<{ id: string; name: string }> = [ - { id: 'volume', name: 'Volume' }, - { id: 'pan', name: 'Pan' }, + { id: "volume", name: "Volume" }, + { id: "pan", name: "Pan" }, ]; 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) { // Determine value range based on parameter type let valueRange = { min: 0, max: 1 }; - let unit = ''; + let unit = ""; let formatter: ((value: number) => string) | undefined; - if (selectedParameterId === 'volume') { - unit = 'dB'; - } else if (selectedParameterId === 'pan') { + if (selectedParameterId === "volume") { + unit = "dB"; + } else if (selectedParameterId === "pan") { formatter = (value: number) => { - 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 "C"; + if (value < 0.5) + return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`; return `${((value - 0.5) * 200).toFixed(0)}R`; }; - } else if (selectedParameterId.startsWith('effect.')) { + } else if (selectedParameterId.startsWith("effect.")) { // Parse effect parameter: effect.{effectId}.{paramName} - const parts = selectedParameterId.split('.'); + const parts = selectedParameterId.split("."); if (parts.length === 3) { const paramName = parts[2]; // Set ranges based on parameter name - if (paramName === 'frequency') { + if (paramName === "frequency") { valueRange = { min: 20, max: 20000 }; - unit = 'Hz'; - } else if (paramName === 'Q') { + unit = "Hz"; + } else if (paramName === "Q") { valueRange = { min: 0.1, max: 20 }; - } else if (paramName === 'gain') { + } else if (paramName === "gain") { valueRange = { min: -40, max: 40 }; - unit = 'dB'; + unit = "dB"; } } } @@ -202,7 +265,7 @@ export function Track({ max: valueRange.max, unit, formatter, - } + }, ); 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 = () => { setIsEditingName(true); - setNameInput(String(track.name || 'Untitled Track')); + setNameInput(String(track.name || "Untitled Track")); }; const handleNameBlur = () => { @@ -226,15 +296,15 @@ export function Track({ if (nameInput.trim()) { onNameChange(nameInput.trim()); } else { - setNameInput(String(track.name || 'Untitled Track')); + setNameInput(String(track.name || "Untitled Track")); } }; const handleNameKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { inputRef.current?.blur(); - } else if (e.key === 'Escape') { - setNameInput(String(track.name || 'Untitled Track')); + } else if (e.key === "Escape") { + setNameInput(String(track.name || "Untitled Track")); setIsEditingName(false); } }; @@ -256,7 +326,7 @@ export function Track({ // Watch for class changes on document element (dark mode toggle) observer.observe(document.documentElement, { attributes: true, - attributeFilter: ['class'], + attributeFilter: ["class"], }); return () => observer.disconnect(); @@ -267,7 +337,7 @@ export function Track({ if (!track.audioBuffer || !canvasRef.current) return; const canvas = canvasRef.current; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (!ctx) return; // Use parent container's size since canvas is absolute positioned @@ -285,7 +355,9 @@ export function Track({ const height = rect.height; // 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.fillRect(0, 0, width, height); @@ -321,7 +393,7 @@ export function Track({ } // Draw center line - ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)'; + ctx.strokeStyle = "rgba(148, 163, 184, 0.2)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, height / 2); @@ -334,11 +406,11 @@ export function Track({ const selEndX = (track.selection.end / duration) * width; // 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); // Draw selection borders - ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)'; + ctx.strokeStyle = "rgba(59, 130, 246, 0.8)"; ctx.lineWidth = 2; // Start border @@ -357,14 +429,24 @@ export function Track({ // Draw playhead if (duration > 0) { 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.beginPath(); ctx.moveTo(playheadX, 0); ctx.lineTo(playheadX, height); 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) => { if (!duration) return; @@ -384,7 +466,8 @@ export function Track({ }; const handleCanvasMouseMove = (e: React.MouseEvent) => { - if (!isSelecting || selectionStart === null || !duration || !dragStartPos) return; + if (!isSelecting || selectionStart === null || !duration || !dragStartPos) + return; const rect = e.currentTarget.getBoundingClientRect(); 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) 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) { @@ -422,7 +506,8 @@ export function Track({ // Check if user actually dragged (check distance directly, not state) const didDrag = dragStartPos ? 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 : false; @@ -450,8 +535,8 @@ export function Track({ } }; - window.addEventListener('mouseup', handleGlobalMouseUp); - return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + window.addEventListener("mouseup", handleGlobalMouseUp); + return () => window.removeEventListener("mouseup", handleGlobalMouseUp); }, [isSelecting]); const handleFileChange = async (e: React.ChangeEvent) => { @@ -472,11 +557,11 @@ export function Track({ setPendingFile(file); setShowImportDialog(true); } catch (error) { - console.error('Failed to read audio file metadata:', error); + console.error("Failed to read audio file metadata:", error); } // Reset input - e.target.value = ''; + e.target.value = ""; }; const handleImport = async (options: ImportOptions) => { @@ -488,14 +573,14 @@ export function Track({ onLoadAudio(buffer); // Update track name to filename if it's still default - if (track.name === 'New Track' || track.name === 'Untitled Track') { - const fileName = metadata.fileName.replace(/\.[^/.]+$/, ''); + if (track.name === "New Track" || track.name === "Untitled Track") { + const fileName = metadata.fileName.replace(/\.[^/.]+$/, ""); onNameChange(fileName); } - console.log('Audio imported:', metadata); + console.log("Audio imported:", metadata); } catch (error) { - console.error('Failed to import audio file:', error); + console.error("Failed to import audio file:", error); } finally { setPendingFile(null); setFileMetadata({}); @@ -535,8 +620,8 @@ export function Track({ if (!file || !onLoadAudio) return; // Check if it's an audio file - if (!file.type.startsWith('audio/')) { - console.warn('Dropped file is not an audio file'); + if (!file.type.startsWith("audio/")) { + console.warn("Dropped file is not an audio file"); return; } @@ -547,12 +632,12 @@ export function Track({ onLoadAudio(audioBuffer); // Update track name to filename if it's still default - if (track.name === 'New Track' || track.name === 'Untitled Track') { - const fileName = file.name.replace(/\.[^/.]+$/, ''); + if (track.name === "New Track" || track.name === "Untitled Track") { + const fileName = file.name.replace(/\.[^/.]+$/, ""); onNameChange(fileName); } } catch (error) { - console.error('Failed to load audio file:', error); + console.error("Failed to load audio file:", error); } }; @@ -567,7 +652,7 @@ export function Track({ setIsResizing(true); resizeStartRef.current = { y: e.clientY, height: track.height }; }, - [track.collapsed, track.height] + [track.collapsed, track.height], ); React.useEffect(() => { @@ -577,7 +662,7 @@ export function Track({ const delta = e.clientY - resizeStartRef.current.y; const newHeight = Math.max( 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 }); }; @@ -586,480 +671,249 @@ export function Track({ setIsResizing(false); }; - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing, onUpdateTrack, track.id]); - return ( -
- {/* Top: Track Row (Control Panel + Waveform) */} -
- {/* Left: Track Control Panel (Fixed Width) - Ableton Style */} + // Render only controls + if (renderControlsOnly) { + return ( +
{ + e.stopPropagation(); + if (onSelect) onSelect(); + }} + > + {/* Track Name Row - Integrated collapse (DAW style) */}
{ - e.stopPropagation(); - if (onSelect) onSelect(); + if (!isEditingName) { + e.stopPropagation(); + onToggleCollapse(); + } }} + title={track.collapsed ? "Expand track" : "Collapse track"} > - {/* Track Name Row - Integrated collapse (DAW style) */} + {/* Small triangle indicator */}
{ - if (!isEditingName) { - e.stopPropagation(); - onToggleCollapse(); - } - }} - title={track.collapsed ? 'Expand track' : 'Collapse track'} - > - {/* Small triangle indicator */} -
- {track.collapsed ? ( - - ) : ( - - )} -
- - {/* Color stripe (thicker when selected) */} -
- - {/* Track name (editable) */} -
- {isEditingName ? ( - 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" - /> - ) : ( -
{ - 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')} -
- )} -
+ isSelected + ? "text-primary" + : "text-muted-foreground group-hover:text-foreground", + )} + > + {track.collapsed ? ( + + ) : ( + + )}
- {/* Track Controls - Only show when not collapsed */} - {!track.collapsed && ( -
- {/* Integrated Track Controls (Pan + Fader + Buttons) */} - { - onUpdateTrack(track.id, { - automation: { - ...track.automation, - showAutomation: !track.automation?.showAutomation, - }, - }); - }} - onEffectsClick={() => { - onUpdateTrack(track.id, { - showEffects: !track.showEffects, - }); - }} - onVolumeTouchStart={handleVolumeTouchStart} - onVolumeTouchEnd={handleVolumeTouchEnd} - onPanTouchStart={handlePanTouchStart} - onPanTouchEnd={handlePanTouchEnd} + {/* Color stripe (thicker when selected) */} +
+
+ {isEditingName ? ( + 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" /> -
- )} + ) : ( +
{ + 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")} +
+ )} +
- {/* Right: Waveform Area (Flexible Width) - Scrollable */} -
+ {/* Track Controls - Only show when not collapsed */} + {!track.collapsed && ( +
+ {/* Integrated Track Controls (Pan + Fader + Buttons) */} + { + onUpdateTrack(track.id, { + automation: { + ...track.automation, + showAutomation: !track.automation?.showAutomation, + }, + }); + }} + onEffectsClick={() => { + onUpdateTrack(track.id, { + showEffects: !track.showEffects, + }); + }} + onVolumeTouchStart={handleVolumeTouchStart} + onVolumeTouchEnd={handleVolumeTouchEnd} + onPanTouchStart={handlePanTouchStart} + onPanTouchEnd={handlePanTouchEnd} + /> +
+ )} +
+ ); + } + + // Render only waveform + if (renderWaveformOnly) { + return ( +
{/* Inner container with dynamic width */}
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 */} + - {track.audioBuffer ? ( - <> - {/* Waveform Canvas */} - - - ) : ( - !track.collapsed && ( + {track.audioBuffer ? ( <> -
{ - e.stopPropagation(); - handleLoadAudioClick(); - }} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - -

{isDragging ? 'Drop audio file here' : 'Click to load audio file'}

-

or drag & drop

-
- - ) - )} -
{/* Close inner container with minWidth */} -
{/* Close waveform scrollable area */} -
{/* Close track row */} - - {/* 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) - const selectedParameterId = track.automation.selectedParameterId || 'volume'; - - // 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 ? ( -
- {/* Left: Automation Controls (matching track controls width - w-48 = 192px) */} -
- {/* Parameter selector dropdown */} - - - {/* Automation mode cycle button */} - - - {/* Height controls */} -
- - -
-
- - {/* Right: Automation Lane Canvas (matching waveform width) */} -
- { - 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 }, - }); - }} - /> -
-
- ) : null; - })()} - - {/* Per-Track Effects Panel */} - {!track.collapsed && track.showEffects && ( -
- {track.effectChain.effects.length === 0 ? ( -
- -
- No effects on this track -
-
) : ( -
- -
- {track.effectChain.effects.map((effect) => ( - 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} - /> - ))} -
-
+ !track.collapsed && ( + <> +
{ + e.stopPropagation(); + handleLoadAudioClick(); + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + +

+ {isDragging + ? "Drop audio file here" + : "Click to load audio file"} +

+

or drag & drop

+
+ + + ) )} +
{" "} + {/* Close inner container with minWidth */} +
+ ); + } - {/* Effect Browser Dialog */} - setEffectBrowserOpen(false)} - onSelectEffect={(effectType) => { - onAddEffect?.(effectType); - setEffectBrowserOpen(false); - }} - /> -
- )} - - {/* Track Height Resize Handle */} - {!track.collapsed && ( -
-
-
- )} - - {/* Import Dialog */} - {showImportDialog && pendingFile && ( - + // Render full track (both controls and waveform side by side) + return ( +
+ {/* Full track content removed - now rendered separately in TrackList */} +
Track component should not be rendered in full mode anymore
); } diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index 56744dc..dbcb466 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -86,96 +86,196 @@ export function TrackList({ ); } + const waveformScrollRef = React.useRef(null); + return (
- {/* Track List */} -
- {tracks.map((track) => ( - 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} - /> - ))} + {/* Track List - Two Column Layout */} +
+ {/* Left Column: Track Controls (Fixed Width, Vertical Scroll) */} +
+ {tracks.map((track) => ( + 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} + renderControlsOnly={true} + /> + ))} +
+ + {/* Right Column: Waveforms (Flexible Width, Shared Horizontal Scroll) */} +
+ {tracks.map((track) => ( + 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} + /> + ))} +
{/* Import Dialog */}