Files
audio-ui/components/automation/AutomationLane.tsx
Sebastian Krüger eb445bfa3a refactor: single automation lane with parameter dropdown and fixed-height effects panel
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>
2025-11-18 18:49:44 +01:00

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>
);
}