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

138 lines
3.6 KiB
TypeScript

'use client';
import { useCallback, useState } from 'react';
import { KeyframeMarker } from './KeyframeMarker';
import { cn } from '@/lib/utils';
interface Keyframe {
id: string;
bar: number;
}
interface KeyframeTrackProps<T extends Keyframe> {
label: string;
icon?: React.ReactNode;
keyframes: T[];
durationBars: number;
pixelsPerBar: number;
selectedId: string | null;
onSelect: (id: string | null) => void;
onAdd: (bar: number) => void;
onDelete: (id: string) => void;
markerColor?: string;
renderPicker?: (props: {
keyframe: T;
onClose: () => void;
position: { x: number; y: number };
}) => React.ReactNode;
className?: string;
}
export function KeyframeTrack<T extends Keyframe>({
label,
icon,
keyframes,
durationBars,
pixelsPerBar,
selectedId,
onSelect,
onAdd,
onDelete,
markerColor = 'bg-primary',
renderPicker,
className,
}: KeyframeTrackProps<T>) {
const [pickerPosition, setPickerPosition] = useState<{ x: number; y: number } | null>(
null
);
const handleTrackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const bar = Math.floor(x / pixelsPerBar);
if (bar >= 0 && bar < durationBars) {
const existingKeyframe = keyframes.find((kf) => kf.bar === bar);
if (existingKeyframe) {
onSelect(existingKeyframe.id);
setPickerPosition({ x: bar * pixelsPerBar, y: 0 });
} else {
onAdd(bar);
}
}
},
[pixelsPerBar, durationBars, keyframes, onSelect, onAdd]
);
const handleMarkerSelect = useCallback(
(keyframe: T) => {
onSelect(keyframe.id);
setPickerPosition({ x: keyframe.bar * pixelsPerBar, y: 0 });
},
[onSelect, pixelsPerBar]
);
const handleClosePicker = useCallback(() => {
onSelect(null);
setPickerPosition(null);
}, [onSelect]);
const selectedKeyframe = selectedId
? keyframes.find((kf) => kf.id === selectedId)
: null;
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
className="ml-20 h-8 bg-muted/10 border-b border-border/30 cursor-pointer relative"
style={{ width: durationBars * pixelsPerBar }}
onClick={handleTrackClick}
>
{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 }}
/>
))}
{keyframes.map((kf) => (
<KeyframeMarker
key={kf.id}
bar={kf.bar}
pixelsPerBar={pixelsPerBar}
color={markerColor}
selected={kf.id === selectedId}
onSelect={() => handleMarkerSelect(kf)}
onDelete={() => onDelete(kf.id)}
/>
))}
{selectedKeyframe && pickerPosition && renderPicker && (
<div
className="absolute top-full mt-1"
style={{ left: Math.min(pickerPosition.x, durationBars * pixelsPerBar - 256) }}
>
{renderPicker({
keyframe: selectedKeyframe,
onClose: handleClosePicker,
position: pickerPosition,
})}
</div>
)}
</div>
</div>
);
}