- Fader handles now respect top-8 bottom-8 track padding - Handle moves only within the visible track lane (60% range) - Updated both TrackFader and MasterFader components - Value calculation clamped to track bounds (32px padding top/bottom) - Handle position mapped to 20%-80% range instead of 0%-100% - Prevents handle from going beyond visible track area 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { cn } from '@/lib/utils/cn';
|
|
|
|
export interface TrackFaderProps {
|
|
value: number;
|
|
peakLevel: number;
|
|
rmsLevel: number;
|
|
onChange: (value: number) => void;
|
|
onTouchStart?: () => void;
|
|
onTouchEnd?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function TrackFader({
|
|
value,
|
|
peakLevel,
|
|
rmsLevel,
|
|
onChange,
|
|
onTouchStart,
|
|
onTouchEnd,
|
|
className,
|
|
}: TrackFaderProps) {
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// Convert linear 0-1 to dB scale for display
|
|
const linearToDb = (linear: number): number => {
|
|
if (linear === 0) return -60;
|
|
const db = 20 * Math.log10(linear);
|
|
return Math.max(-60, Math.min(0, db));
|
|
};
|
|
|
|
const valueDb = linearToDb(value);
|
|
const peakDb = linearToDb(peakLevel);
|
|
const rmsDb = linearToDb(rmsLevel);
|
|
|
|
// Calculate bar widths (0-100%)
|
|
const peakWidth = ((peakDb + 60) / 60) * 100;
|
|
const rmsWidth = ((rmsDb + 60) / 60) * 100;
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
onTouchStart?.();
|
|
updateValue(e.clientY);
|
|
};
|
|
|
|
const handleMouseMove = React.useCallback(
|
|
(e: MouseEvent) => {
|
|
if (!isDragging) return;
|
|
updateValue(e.clientY);
|
|
},
|
|
[isDragging]
|
|
);
|
|
|
|
const handleMouseUp = React.useCallback(() => {
|
|
setIsDragging(false);
|
|
onTouchEnd?.();
|
|
}, [onTouchEnd]);
|
|
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
setIsDragging(true);
|
|
onTouchStart?.();
|
|
updateValue(touch.clientY);
|
|
};
|
|
|
|
const handleTouchMove = React.useCallback(
|
|
(e: TouchEvent) => {
|
|
if (!isDragging || e.touches.length === 0) return;
|
|
const touch = e.touches[0];
|
|
updateValue(touch.clientY);
|
|
},
|
|
[isDragging]
|
|
);
|
|
|
|
const handleTouchEnd = React.useCallback(() => {
|
|
setIsDragging(false);
|
|
onTouchEnd?.();
|
|
}, [onTouchEnd]);
|
|
|
|
const updateValue = (clientY: number) => {
|
|
if (!containerRef.current) return;
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const y = clientY - rect.top;
|
|
|
|
// Track has 32px (2rem) padding on top and bottom (top-8 bottom-8)
|
|
const trackPadding = 32;
|
|
const trackHeight = rect.height - (trackPadding * 2);
|
|
|
|
// Clamp y to track bounds
|
|
const clampedY = Math.max(trackPadding, Math.min(rect.height - trackPadding, y));
|
|
|
|
// Inverted: top = max (1), bottom = min (0)
|
|
// Map clampedY from [trackPadding, height-trackPadding] to [1, 0]
|
|
const percentage = 1 - ((clampedY - trackPadding) / trackHeight);
|
|
onChange(Math.max(0, Math.min(1, percentage)));
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (isDragging) {
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
window.addEventListener('touchmove', handleTouchMove);
|
|
window.addEventListener('touchend', handleTouchEnd);
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
window.removeEventListener('touchmove', handleTouchMove);
|
|
window.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}
|
|
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
|
|
|
return (
|
|
<div className={cn('flex gap-3', className)} style={{ marginLeft: '16px' }}>
|
|
{/* dB Labels (Left) */}
|
|
<div className="flex flex-col justify-between text-[10px] font-mono text-muted-foreground py-1">
|
|
<span>0</span>
|
|
<span>-12</span>
|
|
<span>-24</span>
|
|
<span>-60</span>
|
|
</div>
|
|
|
|
{/* Fader Container */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative w-12 h-40 bg-background/50 rounded-md border border-border/50 cursor-pointer"
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
>
|
|
{/* Peak Meter (Horizontal Bar - Top) */}
|
|
<div className="absolute inset-x-2 top-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 transition-all duration-75 ease-out"
|
|
style={{ width: `${Math.max(0, Math.min(100, peakWidth))}%` }}
|
|
>
|
|
<div className={cn(
|
|
'w-full h-full',
|
|
peakDb > -3 ? 'bg-red-500' :
|
|
peakDb > -6 ? 'bg-yellow-500' :
|
|
'bg-green-500'
|
|
)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* RMS Meter (Horizontal Bar - Bottom) */}
|
|
<div className="absolute inset-x-2 bottom-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 transition-all duration-150 ease-out"
|
|
style={{ width: `${Math.max(0, Math.min(100, rmsWidth))}%` }}
|
|
>
|
|
<div className={cn(
|
|
'w-full h-full',
|
|
rmsDb > -3 ? 'bg-red-500' :
|
|
rmsDb > -6 ? 'bg-yellow-500' :
|
|
'bg-green-500'
|
|
)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fader Track */}
|
|
<div className="absolute top-8 bottom-8 left-1/2 -translate-x-1/2 w-1.5 bg-muted/50 rounded-full" />
|
|
|
|
{/* Fader Handle */}
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2 w-10 h-4 bg-primary/80 border-2 border-primary rounded-md shadow-lg cursor-grab active:cursor-grabbing pointer-events-none transition-all"
|
|
style={{
|
|
// Inverted: value 1 = top of track (20%), value 0 = bottom of track (80%)
|
|
// Track has top-8 bottom-8 padding (20% and 80% of h-40 container)
|
|
// Handle moves within 60% range (from 20% to 80%)
|
|
top: `calc(${20 + (1 - value) * 60}% - 0.5rem)`,
|
|
}}
|
|
>
|
|
{/* Handle grip lines */}
|
|
<div className="absolute inset-0 flex items-center justify-center gap-0.5">
|
|
<div className="h-2 w-px bg-primary-foreground/30" />
|
|
<div className="h-2 w-px bg-primary-foreground/30" />
|
|
<div className="h-2 w-px bg-primary-foreground/30" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* dB Scale Markers */}
|
|
<div className="absolute inset-0 px-2 py-8 pointer-events-none">
|
|
<div className="relative h-full">
|
|
{/* -12 dB */}
|
|
<div className="absolute left-0 right-0 h-px bg-border/20" style={{ top: '50%' }} />
|
|
{/* -6 dB */}
|
|
<div className="absolute left-0 right-0 h-px bg-yellow-500/20" style={{ top: '20%' }} />
|
|
{/* -3 dB */}
|
|
<div className="absolute left-0 right-0 h-px bg-red-500/30" style={{ top: '10%' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Value and Level Display (Right) */}
|
|
<div className="flex flex-col justify-between items-start text-[9px] font-mono py-1 w-[36px]">
|
|
{/* Current dB Value */}
|
|
<div className={cn(
|
|
'font-bold text-[11px]',
|
|
valueDb > -3 ? 'text-red-500' :
|
|
valueDb > -6 ? 'text-yellow-500' :
|
|
'text-green-500'
|
|
)}>
|
|
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
|
|
</div>
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Peak Level */}
|
|
<div className="flex flex-col items-start">
|
|
<span className="text-muted-foreground/60">PK</span>
|
|
<span className={cn(
|
|
'font-mono text-[10px]',
|
|
peakDb > -3 ? 'text-red-500' :
|
|
peakDb > -6 ? 'text-yellow-500' :
|
|
'text-green-500'
|
|
)}>
|
|
{peakDb > -60 ? `${peakDb.toFixed(1)}` : '-∞'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* RMS Level */}
|
|
<div className="flex flex-col items-start">
|
|
<span className="text-muted-foreground/60">RM</span>
|
|
<span className={cn(
|
|
'font-mono text-[10px]',
|
|
rmsDb > -3 ? 'text-red-500' :
|
|
rmsDb > -6 ? 'text-yellow-500' :
|
|
'text-green-500'
|
|
)}>
|
|
{rmsDb > -60 ? `${rmsDb.toFixed(1)}` : '-∞'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* dB Label */}
|
|
<span className="text-muted-foreground/60 text-[8px]">dB</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|