Files
audio-ui/components/ui/VerticalFader.tsx
Sebastian Krüger ecf7f060ec feat: Complete Ableton-style track layout redesign
Track height & spacing improvements:
- Increased DEFAULT_TRACK_HEIGHT from 180px to 240px for vertical controls
- Increased MIN_TRACK_HEIGHT from 60px to 120px
- Increased MAX_TRACK_HEIGHT from 300px to 400px
- Added COLLAPSED_TRACK_HEIGHT constant (48px)
- Reduced control panel gap from 8px to 6px for tighter spacing
- Added min-h-0 and overflow-hidden to prevent flex overflow
- Optimized fader container with max-h-[140px] constraint

Clip-style waveform visualization (Ableton-like):
- Wrapped waveform canvas in visible "clip" container
- Added border, rounded corners, and shadow for clip identity
- Added 16px clip header bar showing track name
- Implemented hover state for better interactivity
- Added gradient background from-foreground/5 to-transparent

Track height resize functionality:
- Added draggable bottom-edge resize handle
- Implemented cursor-ns-resize with hover feedback
- Constrain resizing to MIN/MAX height range
- Real-time height updates with smooth visual feedback
- Active state highlighting during resize

Effects section visual integration:
- Changed from solid background to gradient (from-muted/80 to-muted/60)
- Reduced device rack height from 192px to 176px for better proportion
- Improved visual hierarchy and connection to track row

Flexible VerticalFader component:
- Changed from fixed h-32 (128px) to flex-1 layout
- Added min-h-[80px] and max-h-[140px] constraints
- Allows parent container to control actual height
- Maintains readability and proportions at all sizes

CSS enhancements (globals.css):
- Added .track-clip-container utility class
- Added .track-clip-header utility class
- Theme-aware clip styling for light/dark modes
- OKLCH color space for consistent appearance

Visual results:
- Professional DAW appearance matching Ableton Live
- Clear clip/region boundaries for audio editing
- Better proportions for vertical controls (240px tracks)
- Resizable track heights (120-400px range)
- Improved visual hierarchy and organization

Files modified:
- types/track.ts (height constants)
- components/tracks/Track.tsx (layout + clip styling + resize)
- components/ui/VerticalFader.tsx (flexible height)
- app/globals.css (clip styling)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:41:58 +01:00

166 lines
4.7 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 flex-1 min-h-[80px] max-h-[140px] 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>
);
}