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

128 lines
3.8 KiB
TypeScript

'use client';
import { useCallback, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Repeat } from 'lucide-react';
import { cn } from '@/lib/utils';
import { LoopRegion } from '@/types/audio';
interface LoopBracketProps {
region: LoopRegion;
durationBars: number;
pixelsPerBar: number;
onChange: (region: LoopRegion) => void;
className?: string;
}
export function LoopBracket({
region,
durationBars,
pixelsPerBar,
onChange,
className,
}: LoopBracketProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'start' | 'end' | 'region' | null>(null);
const dragStart = useRef({ x: 0, region: { ...region } });
const left = region.start * pixelsPerBar;
const width = (region.end - region.start) * pixelsPerBar;
const handleMouseDown = useCallback(
(type: 'start' | 'end' | 'region', e: React.MouseEvent) => {
e.preventDefault();
setDragging(type);
dragStart.current = { x: e.clientX, region: { ...region } };
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const deltaX = e.clientX - dragStart.current.x;
const deltaBars = Math.round(deltaX / pixelsPerBar);
const { start, end, enabled } = dragStart.current.region;
let newStart = start;
let newEnd = end;
if (type === 'start') {
newStart = Math.max(0, Math.min(end - 1, start + deltaBars));
} else if (type === 'end') {
newEnd = Math.max(start + 1, Math.min(durationBars, end + deltaBars));
} else if (type === 'region') {
const duration = end - start;
newStart = Math.max(0, Math.min(durationBars - duration, start + deltaBars));
newEnd = newStart + duration;
}
onChange({ start: newStart, end: newEnd, enabled });
};
const handleMouseUp = () => {
setDragging(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[region, pixelsPerBar, durationBars, onChange]
);
const toggleEnabled = useCallback(() => {
onChange({ ...region, enabled: !region.enabled });
}, [region, onChange]);
return (
<div
ref={containerRef}
className={cn('relative h-5', className)}
style={{ width: durationBars * pixelsPerBar }}
>
<div
className={cn(
'absolute top-0 h-full rounded-sm transition-colors',
region.enabled
? 'bg-lofi-orange/20 border border-lofi-orange/50'
: 'bg-muted/20 border border-border/50'
)}
style={{ left, width }}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-lofi-orange/30',
dragging === 'start' && 'bg-lofi-orange/40'
)}
onMouseDown={(e) => handleMouseDown('start', e)}
/>
<div
className="absolute inset-x-2 inset-y-0 cursor-move"
onMouseDown={(e) => handleMouseDown('region', e)}
/>
<div
className={cn(
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-lofi-orange/30',
dragging === 'end' && 'bg-lofi-orange/40'
)}
onMouseDown={(e) => handleMouseDown('end', e)}
/>
</div>
<Button
variant={region.enabled ? 'default' : 'outline'}
size="sm"
className={cn(
'absolute -right-8 top-0 h-5 w-6 p-0',
region.enabled && 'bg-lofi-orange hover:bg-lofi-orange/80'
)}
onClick={toggleEnabled}
>
<Repeat className="h-3 w-3" />
</Button>
</div>
);
}