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
114 lines
2.9 KiB
TypeScript
114 lines
2.9 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface VerticalFaderProps {
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
min?: number;
|
|
max?: number;
|
|
height?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function VerticalFader({
|
|
value,
|
|
onChange,
|
|
min = 0,
|
|
max = 1,
|
|
height = 64,
|
|
className,
|
|
}: VerticalFaderProps) {
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const normalizedValue = (value - min) / (max - min);
|
|
|
|
const handleMove = useCallback(
|
|
(clientY: number) => {
|
|
if (!trackRef.current) return;
|
|
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
const y = clientY - rect.top;
|
|
const percentage = 1 - Math.max(0, Math.min(1, y / rect.height));
|
|
const newValue = min + percentage * (max - min);
|
|
onChange(newValue);
|
|
},
|
|
[min, max, onChange]
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
handleMove(e.clientY);
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
handleMove(e.clientY);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
},
|
|
[handleMove]
|
|
);
|
|
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
setIsDragging(true);
|
|
handleMove(e.touches[0].clientY);
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
handleMove(e.touches[0].clientY);
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setIsDragging(false);
|
|
window.removeEventListener('touchmove', handleTouchMove);
|
|
window.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
|
|
window.addEventListener('touchmove', handleTouchMove);
|
|
window.addEventListener('touchend', handleTouchEnd);
|
|
},
|
|
[handleMove]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={trackRef}
|
|
className={cn(
|
|
'relative w-3 rounded-full cursor-pointer select-none',
|
|
'bg-border/60',
|
|
className
|
|
)}
|
|
style={{ height }}
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
>
|
|
{/* Fill */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 rounded-full bg-primary transition-all duration-75"
|
|
style={{ height: `${normalizedValue * 100}%` }}
|
|
/>
|
|
{/* Thumb */}
|
|
<div
|
|
className={cn(
|
|
'absolute left-1/2 -translate-x-1/2 w-4 h-4 rounded-full',
|
|
'bg-white border-2 border-primary shadow-md',
|
|
'transition-transform duration-75',
|
|
isDragging && 'scale-110'
|
|
)}
|
|
style={{ bottom: `calc(${normalizedValue * 100}% - 8px)` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|