Phase 9 Automation Improvements: - Replaced multiple automation lanes with single lane + parameter selector - Added dropdown in automation header to switch between parameters: - Track parameters: Volume, Pan - Effect parameters: Dynamically generated from effect chain - Lanes are created on-demand when parameter is selected - Effects panel now has fixed height (280px) with scroll - Panel no longer resizes when effects are expanded - Maintains consistent UI layout Technical changes: - Track.automation.selectedParameterId: Tracks current parameter - AutomationHeader: Added parameter dropdown props - AutomationLane: Passes parameter selection to header - Track.tsx: Single lane rendering with IIFE for parameter list - Effects panel: h-[280px] with flex layout and overflow-y-auto 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { cn } from '@/lib/utils/cn';
|
|
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType, AutomationMode } from '@/types/automation';
|
|
import { AutomationHeader } from './AutomationHeader';
|
|
import { AutomationPoint } from './AutomationPoint';
|
|
|
|
export interface AutomationLaneProps {
|
|
lane: AutomationLaneType;
|
|
duration: number; // Total timeline duration in seconds
|
|
zoom: number; // Zoom factor
|
|
currentTime?: number; // Playhead position
|
|
onUpdateLane?: (updates: Partial<AutomationLaneType>) => void;
|
|
onAddPoint?: (time: number, value: number) => void;
|
|
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
|
|
onRemovePoint?: (pointId: string) => void;
|
|
className?: string;
|
|
// Parameter selection
|
|
availableParameters?: Array<{ id: string; name: string }>;
|
|
selectedParameterId?: string;
|
|
onParameterChange?: (parameterId: string) => void;
|
|
}
|
|
|
|
export function AutomationLane({
|
|
lane,
|
|
duration,
|
|
zoom,
|
|
currentTime = 0,
|
|
onUpdateLane,
|
|
onAddPoint,
|
|
onUpdatePoint,
|
|
onRemovePoint,
|
|
className,
|
|
availableParameters,
|
|
selectedParameterId,
|
|
onParameterChange,
|
|
}: AutomationLaneProps) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const [selectedPointId, setSelectedPointId] = React.useState<string | null>(null);
|
|
const [isDraggingPoint, setIsDraggingPoint] = React.useState(false);
|
|
|
|
// Convert time to X pixel position
|
|
const timeToX = React.useCallback(
|
|
(time: number): number => {
|
|
if (!containerRef.current) return 0;
|
|
const width = containerRef.current.clientWidth;
|
|
return (time / duration) * width * zoom;
|
|
},
|
|
[duration, zoom]
|
|
);
|
|
|
|
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
|
|
const valueToY = React.useCallback(
|
|
(value: number): number => {
|
|
if (!containerRef.current) return 0;
|
|
const height = lane.height;
|
|
return height * (1 - value);
|
|
},
|
|
[lane.height]
|
|
);
|
|
|
|
// Convert X pixel position to time
|
|
const xToTime = React.useCallback(
|
|
(x: number): number => {
|
|
if (!containerRef.current) return 0;
|
|
const width = containerRef.current.clientWidth;
|
|
return (x / (width * zoom)) * duration;
|
|
},
|
|
[duration, zoom]
|
|
);
|
|
|
|
// Convert Y pixel position to value (0-1)
|
|
const yToValue = React.useCallback(
|
|
(y: number): number => {
|
|
const height = lane.height;
|
|
return Math.max(0, Math.min(1, 1 - y / height));
|
|
},
|
|
[lane.height]
|
|
);
|
|
|
|
// Draw automation curve
|
|
React.useEffect(() => {
|
|
if (!canvasRef.current || !lane.visible) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Background
|
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || 'rgb(15, 23, 42)';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// Grid lines (horizontal value guides)
|
|
ctx.strokeStyle = 'rgba(148, 163, 184, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = (height / 4) * i;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw automation curve
|
|
if (lane.points.length > 0) {
|
|
const color = lane.color || 'rgb(59, 130, 246)';
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
|
|
// Sort points by time
|
|
const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time);
|
|
|
|
// Draw lines between points
|
|
for (let i = 0; i < sortedPoints.length; i++) {
|
|
const point = sortedPoints[i];
|
|
const x = timeToX(point.time);
|
|
const y = valueToY(point.value);
|
|
|
|
if (i === 0) {
|
|
// Start from left edge at first point's value
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(x, y);
|
|
} else {
|
|
const prevPoint = sortedPoints[i - 1];
|
|
const prevX = timeToX(prevPoint.time);
|
|
const prevY = valueToY(prevPoint.value);
|
|
|
|
if (point.curve === 'step') {
|
|
// Step curve: horizontal then vertical
|
|
ctx.lineTo(x, prevY);
|
|
ctx.lineTo(x, y);
|
|
} else {
|
|
// Linear curve (bezier not implemented yet)
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
// Extend to right edge from last point
|
|
if (i === sortedPoints.length - 1) {
|
|
ctx.lineTo(width, y);
|
|
}
|
|
}
|
|
|
|
ctx.stroke();
|
|
|
|
// Fill area under curve
|
|
ctx.globalAlpha = 0.2;
|
|
ctx.fillStyle = color;
|
|
ctx.lineTo(width, height);
|
|
ctx.lineTo(0, height);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1.0;
|
|
}
|
|
|
|
// Draw playhead
|
|
if (currentTime >= 0 && duration > 0) {
|
|
const playheadX = timeToX(currentTime);
|
|
if (playheadX >= 0 && playheadX <= width) {
|
|
ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(playheadX, 0);
|
|
ctx.lineTo(playheadX, height);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}, [lane, duration, zoom, currentTime, timeToX, valueToY]);
|
|
|
|
// Handle canvas click to add point
|
|
const handleCanvasClick = React.useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (isDraggingPoint || !onAddPoint) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
const time = xToTime(x);
|
|
const value = yToValue(y);
|
|
|
|
onAddPoint(time, value);
|
|
},
|
|
[isDraggingPoint, onAddPoint, xToTime, yToValue]
|
|
);
|
|
|
|
// Handle point drag
|
|
const handlePointDragStart = React.useCallback((pointId: string) => {
|
|
setIsDraggingPoint(true);
|
|
setSelectedPointId(pointId);
|
|
}, []);
|
|
|
|
const handlePointDrag = React.useCallback(
|
|
(pointId: string, deltaX: number, deltaY: number) => {
|
|
if (!containerRef.current || !onUpdatePoint) return;
|
|
|
|
const point = lane.points.find((p) => p.id === pointId);
|
|
if (!point) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const width = rect.width;
|
|
|
|
// Calculate new time and value
|
|
const timePerPixel = duration / (width * zoom);
|
|
const valuePerPixel = 1 / lane.height;
|
|
|
|
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
|
|
const newValue = Math.max(0, Math.min(1, point.value - deltaY * valuePerPixel));
|
|
|
|
onUpdatePoint(pointId, { time: newTime, value: newValue });
|
|
},
|
|
[lane.points, lane.height, duration, zoom, onUpdatePoint]
|
|
);
|
|
|
|
const handlePointDragEnd = React.useCallback(() => {
|
|
setIsDraggingPoint(false);
|
|
}, []);
|
|
|
|
// Handle point click (select)
|
|
const handlePointClick = React.useCallback((pointId: string, event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
setSelectedPointId(pointId);
|
|
}, []);
|
|
|
|
// Handle point double-click (delete)
|
|
const handlePointDoubleClick = React.useCallback(
|
|
(pointId: string) => {
|
|
if (onRemovePoint) {
|
|
onRemovePoint(pointId);
|
|
}
|
|
},
|
|
[onRemovePoint]
|
|
);
|
|
|
|
// Handle keyboard delete
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPointId && onRemovePoint) {
|
|
e.preventDefault();
|
|
onRemovePoint(selectedPointId);
|
|
setSelectedPointId(null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [selectedPointId, onRemovePoint]);
|
|
|
|
// Get current value at playhead (interpolated)
|
|
const getCurrentValue = React.useCallback((): number | undefined => {
|
|
if (lane.points.length === 0) return undefined;
|
|
|
|
const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time);
|
|
|
|
// Find surrounding points
|
|
let prevPoint = sortedPoints[0];
|
|
let nextPoint = sortedPoints[sortedPoints.length - 1];
|
|
|
|
for (let i = 0; i < sortedPoints.length - 1; i++) {
|
|
if (sortedPoints[i].time <= currentTime && sortedPoints[i + 1].time >= currentTime) {
|
|
prevPoint = sortedPoints[i];
|
|
nextPoint = sortedPoints[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Interpolate
|
|
if (currentTime <= prevPoint.time) return prevPoint.value;
|
|
if (currentTime >= nextPoint.time) return nextPoint.value;
|
|
|
|
const timeDelta = nextPoint.time - prevPoint.time;
|
|
const valueDelta = nextPoint.value - prevPoint.value;
|
|
const progress = (currentTime - prevPoint.time) / timeDelta;
|
|
|
|
return prevPoint.value + valueDelta * progress;
|
|
}, [lane.points, currentTime]);
|
|
|
|
if (!lane.visible) return null;
|
|
|
|
return (
|
|
<div className={cn('flex flex-col border-b border-border/50', className)} style={{ height: lane.height + 30 }}>
|
|
{/* Header */}
|
|
<AutomationHeader
|
|
parameterName={lane.parameterName}
|
|
currentValue={getCurrentValue()}
|
|
visible={lane.visible}
|
|
mode={lane.mode}
|
|
color={lane.color}
|
|
onToggleVisible={() => onUpdateLane?.({ visible: !lane.visible })}
|
|
onModeChange={(mode: AutomationMode) => onUpdateLane?.({ mode })}
|
|
onHeightChange={(delta) => {
|
|
const newHeight = Math.max(60, Math.min(180, lane.height + delta));
|
|
onUpdateLane?.({ height: newHeight });
|
|
}}
|
|
formatter={lane.valueRange.formatter}
|
|
availableParameters={availableParameters}
|
|
selectedParameterId={selectedParameterId}
|
|
onParameterChange={onParameterChange}
|
|
/>
|
|
|
|
{/* Lane canvas area */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative flex-1 bg-background/30 overflow-hidden cursor-crosshair"
|
|
style={{ height: lane.height }}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 w-full h-full"
|
|
onClick={handleCanvasClick}
|
|
/>
|
|
|
|
{/* Automation points */}
|
|
{lane.points.map((point) => (
|
|
<AutomationPoint
|
|
key={point.id}
|
|
point={point}
|
|
x={timeToX(point.time)}
|
|
y={valueToY(point.value)}
|
|
isSelected={selectedPointId === point.id}
|
|
onDragStart={handlePointDragStart}
|
|
onDrag={handlePointDrag}
|
|
onDragEnd={handlePointDragEnd}
|
|
onClick={handlePointClick}
|
|
onDoubleClick={handlePointDoubleClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|