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
82 lines
2.4 KiB
TypeScript
82 lines
2.4 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface PanKnobProps {
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
className?: string;
|
|
size?: number;
|
|
}
|
|
|
|
export function PanKnob({ value, onChange, className, size = 32 }: PanKnobProps) {
|
|
const knobRef = useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const startY = useRef(0);
|
|
const startValue = useRef(0);
|
|
|
|
const rotation = value * 135;
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
startY.current = e.clientY;
|
|
startValue.current = value;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = (startY.current - e.clientY) / 100;
|
|
const newValue = Math.max(-1, Math.min(1, startValue.current + delta));
|
|
onChange(newValue);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
},
|
|
[value, onChange]
|
|
);
|
|
|
|
const handleDoubleClick = useCallback(() => {
|
|
onChange(0);
|
|
}, [onChange]);
|
|
|
|
const label = value === 0 ? 'C' : value < 0 ? 'L' : 'R';
|
|
|
|
return (
|
|
<div className={cn('flex flex-col items-center gap-1', className)}>
|
|
<div
|
|
ref={knobRef}
|
|
className={cn(
|
|
'relative rounded-full bg-muted border-2 border-border cursor-ns-resize',
|
|
'hover:border-primary/50 transition-colors',
|
|
isDragging && 'border-primary'
|
|
)}
|
|
style={{ width: size, height: size }}
|
|
onMouseDown={handleMouseDown}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<div
|
|
className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2 origin-bottom"
|
|
style={{
|
|
transform: `translateX(-50%) rotate(${rotation}deg)`,
|
|
transformOrigin: `50% ${size / 2 - 4}px`,
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center text-[8px] font-mono text-muted-foreground"
|
|
>
|
|
{label}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground uppercase">Pan</span>
|
|
</div>
|
|
);
|
|
}
|