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

131 lines
3.8 KiB
TypeScript

'use client';
import { useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { Section, SECTION_COLORS } from '@/types/audio';
interface SectionBlockProps {
section: Section;
pixelsPerBar: number;
durationBars: number;
onResize: (id: string, startBar: number, endBar: number) => void;
onMove: (id: string, startBar: number) => void;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
selected?: boolean;
}
export function SectionBlock({
section,
pixelsPerBar,
durationBars,
onResize,
onMove,
onSelect,
onDelete,
selected = false,
}: SectionBlockProps) {
const [dragging, setDragging] = useState<'move' | 'start' | 'end' | null>(null);
const dragStart = useRef({ x: 0, startBar: 0, endBar: 0 });
const left = section.startBar * pixelsPerBar;
const width = (section.endBar - section.startBar) * pixelsPerBar;
const color = SECTION_COLORS[section.type];
const handleMouseDown = useCallback(
(type: 'move' | 'start' | 'end', e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDragging(type);
dragStart.current = {
x: e.clientX,
startBar: section.startBar,
endBar: section.endBar,
};
onSelect(section.id);
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - dragStart.current.x;
const deltaBars = Math.round(deltaX / pixelsPerBar);
const { startBar, endBar } = dragStart.current;
if (type === 'start') {
const newStart = Math.max(0, Math.min(endBar - 1, startBar + deltaBars));
onResize(section.id, newStart, endBar);
} else if (type === 'end') {
const newEnd = Math.max(startBar + 1, Math.min(durationBars, endBar + deltaBars));
onResize(section.id, startBar, newEnd);
} else {
const duration = endBar - startBar;
const newStart = Math.max(
0,
Math.min(durationBars - duration, startBar + deltaBars)
);
onMove(section.id, newStart);
}
};
const handleMouseUp = () => {
setDragging(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[section, pixelsPerBar, durationBars, onResize, onMove, onSelect]
);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onDelete(section.id);
},
[section.id, onDelete]
);
return (
<div
className={cn(
'absolute top-1 bottom-1 rounded-md cursor-move',
'transition-shadow',
selected && 'ring-2 ring-white/50 shadow-lg'
)}
style={{
left,
width,
backgroundColor: color,
}}
onContextMenu={handleContextMenu}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20 rounded-l-md',
dragging === 'start' && 'bg-white/30'
)}
onMouseDown={(e) => handleMouseDown('start', e)}
/>
<div
className="absolute inset-x-2 inset-y-0 flex items-center px-1 overflow-hidden"
onMouseDown={(e) => handleMouseDown('move', e)}
>
<span className="text-[10px] font-medium text-white/90 truncate">
{section.name || section.type}
</span>
</div>
<div
className={cn(
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20 rounded-r-md',
dragging === 'end' && 'bg-white/30'
)}
onMouseDown={(e) => handleMouseDown('end', e)}
/>
</div>
);
}