Added comprehensive touch event handling for mobile/tablet support: CircularKnob.tsx: - Added handleTouchStart, handleTouchMove, handleTouchEnd handlers - Touch events use same drag logic as mouse events - Prevents default to avoid scrolling while adjusting TrackFader.tsx: - Added touch event handlers for vertical fader control - Integrated with existing onTouchStart/onTouchEnd callbacks - Supports touch-based automation recording MasterFader.tsx: - Added touch event handlers matching TrackFader - Complete touch support for master volume control All components now work seamlessly on touch-enabled devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
204 lines
6.6 KiB
TypeScript
204 lines
6.6 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;
|
|
// Inverted: top = max (1), bottom = min (0)
|
|
const percentage = Math.max(0, Math.min(1, 1 - (y / rect.height)));
|
|
onChange(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-2', className)} style={{ marginLeft: '16px' }}>
|
|
{/* dB Labels (Left) */}
|
|
<div className="flex flex-col justify-between text-[9px] 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-10 h-32 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-1.5 top-1.5 h-2.5 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-1.5 bottom-1.5 h-2.5 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-6 bottom-6 left-1/2 -translate-x-1/2 w-1 bg-muted/50 rounded-full" />
|
|
|
|
{/* Fader Handle */}
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2 w-9 h-3.5 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, value 0 = bottom
|
|
top: `calc(${(1 - value) * 100}% - 0.4375rem)`,
|
|
}}
|
|
>
|
|
{/* Handle grip lines */}
|
|
<div className="absolute inset-0 flex items-center justify-center gap-0.5">
|
|
<div className="h-1.5 w-px bg-primary-foreground/30" />
|
|
<div className="h-1.5 w-px bg-primary-foreground/30" />
|
|
<div className="h-1.5 w-px bg-primary-foreground/30" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* dB Scale Markers */}
|
|
<div className="absolute inset-0 px-1.5 py-6 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 Display (Right) */}
|
|
<div className="flex flex-col justify-center items-start text-[9px] font-mono">
|
|
{/* Current dB Value */}
|
|
<div className={cn(
|
|
'font-bold text-[10px] w-[32px]',
|
|
valueDb > -3 ? 'text-red-500' :
|
|
valueDb > -6 ? 'text-yellow-500' :
|
|
'text-green-500'
|
|
)}>
|
|
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|