Lofi_Generator/components/timeline/AutomationLane.tsx
Nicholai d3158e4c6a feat(timeline-mixer): WIP timeline and mixer components
work in progress implementation of:
- mixer with channel strips, faders, pan knobs, level meters
- timeline with ruler, playhead, sections, keyframe tracks
- pattern and progression pickers for drums/chords
- automation lanes and mute tracks
- loop bracket for loop region selection
- export modal placeholder

known issues:
- drum pattern changes don't update audio engine
- timeline keyframes not connected to scheduler
- some UI bugs remain

this is a checkpoint commit for further iteration
2026-01-20 18:22:10 -07:00

198 lines
5.9 KiB
TypeScript

'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { AutomationPoint } from '@/types/audio';
interface AutomationLaneProps {
label: string;
icon?: React.ReactNode;
points: AutomationPoint[];
durationBars: number;
pixelsPerBar: number;
height?: number;
onAddPoint: (bar: number, beat: number, value: number) => void;
onUpdatePoint: (id: string, bar: number, beat: number, value: number) => void;
onDeletePoint: (id: string) => void;
className?: string;
}
export function AutomationLane({
label,
icon,
points,
durationBars,
pixelsPerBar,
height = 48,
onAddPoint,
onUpdatePoint,
onDeletePoint,
className,
}: AutomationLaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const pixelsPerBeat = pixelsPerBar / 4;
const width = durationBars * pixelsPerBar;
const sortedPoints = useMemo(() => {
return [...points].sort((a, b) => {
if (a.bar !== b.bar) return a.bar - b.bar;
return a.beat - b.beat;
});
}, [points]);
const pathD = useMemo(() => {
if (sortedPoints.length === 0) return '';
const toX = (bar: number, beat: number) => bar * pixelsPerBar + beat * pixelsPerBeat;
const toY = (value: number) => height - value * height;
let d = `M 0 ${toY(sortedPoints[0]?.value ?? 0.5)}`;
if (sortedPoints.length > 0) {
d = `M ${toX(sortedPoints[0].bar, sortedPoints[0].beat)} ${toY(sortedPoints[0].value)}`;
for (let i = 1; i < sortedPoints.length; i++) {
const p = sortedPoints[i];
d += ` L ${toX(p.bar, p.beat)} ${toY(p.value)}`;
}
}
return d;
}, [sortedPoints, pixelsPerBar, pixelsPerBeat, height]);
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (draggingId) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const totalBeats = x / pixelsPerBeat;
const bar = Math.floor(totalBeats / 4);
const beat = Math.floor(totalBeats % 4);
const value = Math.max(0, Math.min(1, 1 - y / height));
if (bar >= 0 && bar < durationBars) {
onAddPoint(bar, beat, value);
}
},
[draggingId, pixelsPerBeat, height, durationBars, onAddPoint]
);
const handlePointMouseDown = useCallback(
(point: AutomationPoint, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDraggingId(point.id);
setSelectedId(point.id);
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const totalBeats = Math.max(0, x / pixelsPerBeat);
const bar = Math.min(durationBars - 1, Math.floor(totalBeats / 4));
const beat = Math.floor(totalBeats % 4);
const value = Math.max(0, Math.min(1, 1 - y / height));
onUpdatePoint(point.id, bar, beat, value);
};
const handleMouseUp = () => {
setDraggingId(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[pixelsPerBeat, height, durationBars, onUpdatePoint]
);
const handlePointContextMenu = useCallback(
(point: AutomationPoint, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDeletePoint(point.id);
},
[onDeletePoint]
);
return (
<div className={cn('relative', className)}>
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
{icon}
<span className="text-xs font-medium text-muted-foreground truncate">
{label}
</span>
</div>
<div
ref={containerRef}
className="ml-20 bg-muted/10 border-b border-border/30 cursor-crosshair relative"
style={{ width, height }}
onClick={handleContainerClick}
>
{Array.from({ length: durationBars }, (_, i) => (
<div
key={i}
className={cn(
'absolute top-0 bottom-0 border-l',
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
)}
style={{ left: i * pixelsPerBar }}
/>
))}
<svg
className="absolute inset-0 pointer-events-none"
width={width}
height={height}
>
{pathD && (
<path
d={pathD}
fill="none"
stroke="var(--lofi-orange)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
{sortedPoints.map((point) => {
const x = point.bar * pixelsPerBar + point.beat * pixelsPerBeat;
const y = height - point.value * height;
return (
<div
key={point.id}
className={cn(
'absolute w-3 h-3 rounded-full cursor-move',
'transform -translate-x-1/2 -translate-y-1/2',
'border-2 transition-transform',
selectedId === point.id
? 'bg-lofi-orange border-white scale-125'
: 'bg-lofi-orange/80 border-lofi-orange hover:scale-110',
draggingId === point.id && 'scale-125'
)}
style={{ left: x, top: y }}
onMouseDown={(e) => handlePointMouseDown(point, e)}
onContextMenu={(e) => handlePointContextMenu(point, e)}
/>
);
})}
</div>
</div>
);
}