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

118 lines
3.0 KiB
TypeScript

'use client';
import { useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { MuteKeyframe } from '@/types/audio';
interface MuteTrackProps {
label: string;
icon?: React.ReactNode;
keyframes: MuteKeyframe[];
durationBars: number;
pixelsPerBar: number;
onToggle: (bar: number) => void;
className?: string;
}
interface MuteRegion {
startBar: number;
endBar: number;
}
export function MuteTrack({
label,
icon,
keyframes,
durationBars,
pixelsPerBar,
onToggle,
className,
}: MuteTrackProps) {
const mutedRegions = useMemo(() => {
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
const regions: MuteRegion[] = [];
let currentMuted = false;
let regionStart = 0;
sorted.forEach((kf) => {
if (kf.muted && !currentMuted) {
regionStart = kf.bar;
currentMuted = true;
} else if (!kf.muted && currentMuted) {
regions.push({ startBar: regionStart, endBar: kf.bar });
currentMuted = false;
}
});
if (currentMuted) {
regions.push({ startBar: regionStart, endBar: durationBars });
}
return regions;
}, [keyframes, durationBars]);
const handleClick = 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) {
onToggle(bar);
}
},
[pixelsPerBar, durationBars, onToggle]
);
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-6 bg-muted/10 border-b border-border/30 cursor-pointer relative"
style={{ width: durationBars * pixelsPerBar }}
onClick={handleClick}
>
{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 }}
/>
))}
{mutedRegions.map((region, i) => (
<div
key={i}
className="absolute top-1 bottom-1 bg-red-500/30 rounded-sm border border-red-500/50"
style={{
left: region.startBar * pixelsPerBar,
width: (region.endBar - region.startBar) * pixelsPerBar,
}}
/>
))}
{keyframes.map((kf) => (
<div
key={kf.id}
className={cn(
'absolute top-1/2 -translate-y-1/2 w-1 h-4 rounded-full',
kf.muted ? 'bg-red-500' : 'bg-emerald-500'
)}
style={{ left: kf.bar * pixelsPerBar - 2 }}
/>
))}
</div>
</div>
);
}