Major UI Redesign: - Reduced track control width from 288px to 192px (33% narrower) - Replaced horizontal sliders with vertical fader and circular knob - More compact, professional appearance matching Ableton Live - Global settings dialog replaces inline recording settings New Components Created: - VerticalFader.tsx: Vertical volume control with integrated level meter - Shows volume dB at top, level dB at bottom - Level meter displayed as background gradient - Draggable handle for precise control - CircularKnob.tsx: Rotary pan control - SVG-based rotary knob with arc indicator - Vertical drag interaction (200px sensitivity) - Displays L/C/R values - GlobalSettingsDialog.tsx: Centralized settings - Tabbed interface (Recording, Playback, Interface) - Recording settings moved from inline to dialog - Accessible via gear icon in header - Modal dialog with backdrop Track Control Panel Changes: - Track name: More compact (text-xs) - Buttons: Smaller (h-6 w-6), text-based S/M buttons - Record button: Circle indicator instead of icon - Pan: Circular knob (40px) instead of horizontal slider - Volume: Vertical fader with integrated meter - Removed: Inline recording settings panel Header Changes: - Added Settings button (gear icon) before ThemeToggle - Opens GlobalSettingsDialog on click - Clean, accessible from anywhere Props Cleanup: - Removed recordingSettings props from Track/TrackList - Removed onInputGainChange, onRecordMonoChange, onSampleRateChange props - Settings now managed globally via dialog Technical Details: - VerticalFader uses mouse drag for smooth control - CircularKnob rotates -135° to +135° (270° range) - Global event listeners for drag interactions - Proper cleanup on unmount Benefits: ✅ 33% narrower tracks = more tracks visible ✅ Professional Ableton-style appearance ✅ Cleaner, less cluttered interface ✅ Global settings accessible anywhere ✅ Better use of vertical space ✅ Consistent with industry-standard DAWs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { cn } from '@/lib/utils/cn';
|
|
|
|
export interface CircularKnobProps {
|
|
value: number; // -1.0 to 1.0 for pan
|
|
onChange: (value: number) => void;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
size?: number;
|
|
className?: string;
|
|
label?: string;
|
|
formatValue?: (value: number) => string;
|
|
}
|
|
|
|
export function CircularKnob({
|
|
value,
|
|
onChange,
|
|
min = -1,
|
|
max = 1,
|
|
step = 0.01,
|
|
size = 48,
|
|
className,
|
|
label,
|
|
formatValue,
|
|
}: CircularKnobProps) {
|
|
const knobRef = React.useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const dragStartRef = React.useRef({ x: 0, y: 0, value: 0 });
|
|
|
|
const updateValue = React.useCallback(
|
|
(clientX: number, clientY: number) => {
|
|
if (!knobRef.current) return;
|
|
|
|
const rect = knobRef.current.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
const centerY = rect.top + rect.height / 2;
|
|
|
|
// Calculate vertical drag distance from start
|
|
const deltaY = dragStartRef.current.y - clientY;
|
|
const sensitivity = 200; // pixels for full range
|
|
const range = max - min;
|
|
const delta = (deltaY / sensitivity) * range;
|
|
|
|
let newValue = dragStartRef.current.value + delta;
|
|
|
|
// Snap to step
|
|
if (step) {
|
|
newValue = Math.round(newValue / step) * step;
|
|
}
|
|
|
|
// Clamp to range
|
|
newValue = Math.max(min, Math.min(max, newValue));
|
|
|
|
onChange(newValue);
|
|
},
|
|
[min, max, step, onChange]
|
|
);
|
|
|
|
const handleMouseDown = React.useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
dragStartRef.current = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
value,
|
|
};
|
|
},
|
|
[value]
|
|
);
|
|
|
|
const handleMouseMove = React.useCallback(
|
|
(e: MouseEvent) => {
|
|
if (isDragging) {
|
|
updateValue(e.clientX, e.clientY);
|
|
}
|
|
},
|
|
[isDragging, updateValue]
|
|
);
|
|
|
|
const handleMouseUp = React.useCallback(() => {
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (isDragging) {
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}
|
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
|
|
// Calculate rotation angle (-135deg to 135deg, 270deg range)
|
|
const percentage = (value - min) / (max - min);
|
|
const angle = -135 + percentage * 270;
|
|
|
|
const displayValue = formatValue
|
|
? formatValue(value)
|
|
: value === 0
|
|
? 'C'
|
|
: value < 0
|
|
? `L${Math.abs(Math.round(value * 100))}`
|
|
: `R${Math.round(value * 100)}`;
|
|
|
|
return (
|
|
<div className={cn('flex flex-col items-center gap-1', className)}>
|
|
{label && (
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
{label}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={knobRef}
|
|
onMouseDown={handleMouseDown}
|
|
className="relative cursor-pointer select-none"
|
|
style={{ width: size, height: size }}
|
|
>
|
|
{/* Outer ring */}
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
className="absolute inset-0"
|
|
>
|
|
{/* Background arc */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={size / 2 - 4}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className="text-muted/30"
|
|
/>
|
|
|
|
{/* Value arc */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={size / 2 - 4}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
className="text-primary"
|
|
strokeDasharray={`${(percentage * 270 * Math.PI * (size / 2 - 4)) / 180} ${(Math.PI * 2 * (size / 2 - 4))}`}
|
|
transform={`rotate(-225 ${size / 2} ${size / 2})`}
|
|
/>
|
|
</svg>
|
|
|
|
{/* Knob body */}
|
|
<div
|
|
className="absolute inset-0 rounded-full bg-card border-2 border-border shadow-sm flex items-center justify-center transition-transform hover:scale-105 active:scale-95"
|
|
style={{
|
|
transform: `rotate(${angle}deg)`,
|
|
margin: '4px',
|
|
}}
|
|
>
|
|
{/* Indicator line */}
|
|
<div className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2" />
|
|
</div>
|
|
|
|
{/* Center dot (for zero position) */}
|
|
{value === 0 && (
|
|
<div className="absolute top-1/2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2 -translate-y-1/2" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Value Display */}
|
|
<div className="text-[10px] font-medium text-foreground min-w-[32px] text-center">
|
|
{displayValue}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|