forked from averyfelts/Lofi_Generator
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
118 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
}
|