Files
audio-ui/components/ui/VerticalFader.tsx
Sebastian Krüger dc9647731d feat: redesign track controls to Ableton Live style
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>
2025-11-18 16:15:04 +01:00

166 lines
4.6 KiB
TypeScript

'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface VerticalFaderProps {
value: number; // 0.0 to 1.0
level?: number; // 0.0 to 1.0 (for level meter display)
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
className?: string;
showDb?: boolean;
}
export function VerticalFader({
value,
level = 0,
onChange,
min = 0,
max = 1,
step = 0.01,
className,
showDb = true,
}: VerticalFaderProps) {
const trackRef = React.useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = React.useState(false);
const updateValue = React.useCallback(
(clientY: number) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const height = rect.height;
const y = Math.max(0, Math.min(height, clientY - rect.top));
// Invert Y (top = max, bottom = min)
const percentage = 1 - y / height;
const range = max - min;
let newValue = min + percentage * range;
// 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);
updateValue(e.clientY);
},
[updateValue]
);
const handleMouseMove = React.useCallback(
(e: MouseEvent) => {
if (isDragging) {
updateValue(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]);
// Convert value to percentage (0-100)
const valuePercentage = ((value - min) / (max - min)) * 100;
// Convert level to dB for display
const db = value === 0 ? -Infinity : 20 * Math.log10(value);
const levelDb = level === 0 ? -Infinity : (level * 60) - 60;
return (
<div className={cn('flex flex-col items-center gap-1', className)}>
{/* dB Display */}
{showDb && (
<div className="text-[10px] font-mono text-muted-foreground min-w-[32px] text-center">
{db === -Infinity ? '-∞' : `${db.toFixed(1)}`}
</div>
)}
{/* Fader Track */}
<div
ref={trackRef}
onMouseDown={handleMouseDown}
className="relative w-8 h-32 bg-muted rounded cursor-pointer select-none overflow-hidden"
>
{/* Level Meter Background (green/yellow/red gradient) */}
<div
className="absolute inset-0 opacity-40"
style={{
background: 'linear-gradient(to top, rgb(34, 197, 94) 0%, rgb(34, 197, 94) 70%, rgb(234, 179, 8) 85%, rgb(239, 68, 68) 100%)',
}}
/>
{/* Level Meter (actual level) */}
<div
className="absolute bottom-0 left-0 right-0 transition-all duration-75"
style={{
height: `${level * 100}%`,
background: 'linear-gradient(to top, rgb(34, 197, 94) 0%, rgb(34, 197, 94) 70%, rgb(234, 179, 8) 85%, rgb(239, 68, 68) 100%)',
opacity: 0.6,
}}
/>
{/* Volume Value Fill */}
<div
className="absolute bottom-0 left-0 right-0 bg-primary/30 border-t-2 border-primary"
style={{ height: `${valuePercentage}%` }}
/>
{/* Fader Handle */}
<div
className="absolute left-0 right-0 h-3 -ml-1 -mr-1 bg-primary rounded-sm shadow-lg cursor-grab active:cursor-grabbing"
style={{
bottom: `calc(${valuePercentage}% - 6px)`,
width: 'calc(100% + 8px)',
}}
/>
{/* Scale Marks */}
<div className="absolute inset-0 pointer-events-none">
{[0.25, 0.5, 0.75].map((mark) => (
<div
key={mark}
className="absolute left-0 right-0 h-px bg-background/50"
style={{ bottom: `${mark * 100}%` }}
/>
))}
</div>
</div>
{/* Level dB Display */}
{showDb && (
<div className="text-[10px] font-mono text-muted-foreground min-w-[32px] text-center">
{levelDb === -Infinity ? '-∞' : `${levelDb.toFixed(0)}`}
</div>
)}
</div>
);
}