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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user