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

95 lines
2.4 KiB
TypeScript

'use client';
import { useCallback, useState } from 'react';
import { cn } from '@/lib/utils';
interface PlayheadProps {
bar: number;
beat: number;
pixelsPerBar: number;
height: number;
durationBars: number;
onSeek?: (bar: number, beat: number) => void;
className?: string;
}
export function Playhead({
bar,
beat,
pixelsPerBar,
height,
durationBars,
onSeek,
className,
}: PlayheadProps) {
const [isDragging, setIsDragging] = useState(false);
const pixelsPerBeat = pixelsPerBar / 4;
const position = bar * pixelsPerBar + beat * pixelsPerBeat;
const handleDrag = useCallback(
(clientX: number, containerLeft: number) => {
if (!onSeek) return;
const x = clientX - containerLeft;
const totalBeats = x / pixelsPerBeat;
const newBar = Math.floor(totalBeats / 4);
const newBeat = Math.floor(totalBeats % 4);
if (newBar >= 0 && newBar < durationBars) {
onSeek(newBar, Math.max(0, Math.min(3, newBeat)));
}
},
[onSeek, pixelsPerBeat, durationBars]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!onSeek) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const container = (e.currentTarget as HTMLElement).parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const handleMouseMove = (e: MouseEvent) => {
handleDrag(e.clientX, containerRect.left);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
},
[onSeek, handleDrag]
);
return (
<div
className={cn(
'absolute top-0 z-20',
'flex flex-col items-center',
className
)}
style={{ left: position, height }}
>
<div
className={cn(
'w-4 h-3 bg-lofi-orange rounded-sm -mt-0.5 cursor-grab',
isDragging && 'cursor-grabbing scale-110'
)}
style={{
clipPath: 'polygon(50% 100%, 0% 0%, 100% 0%)',
}}
onMouseDown={handleMouseDown}
/>
<div className="w-0.5 flex-1 bg-lofi-orange shadow-[0_0_8px_var(--lofi-orange)] pointer-events-none" />
</div>
);
}