Compare commits
36 Commits
c7cb0b2504
...
5d9e02fe95
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9e02fe95 | |||
| 854e64b4ec | |||
| e7bd262e6f | |||
| ba2e138ab9 | |||
| 7b4a7cc567 | |||
| 64864cfd34 | |||
| 14a9c6e163 | |||
| 0babc469cc | |||
| 6658bbbbd4 | |||
| 9b07f28995 | |||
| d08482a64c | |||
| 1a66669a77 | |||
| 4c794dd293 | |||
| 29de647b30 | |||
| 83ae2e7ea7 | |||
| 950c0f69a6 | |||
| a2542ac87f | |||
| 7aebc1da24 | |||
| 42b8f61f5f | |||
| 35a6ee35d0 | |||
| 235fc3913c | |||
| 0e59870884 | |||
| 8c779ccd88 | |||
| b57ac5912a | |||
| d2ed7d6e78 | |||
| cd310ce7e4 | |||
| 594ff7f4c9 | |||
| ca63d12cbf | |||
| 7a7d6891cd | |||
| 90e66e8bef | |||
| e0b878daad | |||
| 39ea599f18 | |||
| 45d46067ea | |||
| d7dfb8a746 | |||
| 5dadba9c9f | |||
| cd311d8145 |
@@ -69,10 +69,13 @@ export function AutomationHeader({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex items-center gap-2 px-2 py-1 bg-muted/50 border-b border-border/30 flex-shrink-0',
|
'flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30 flex-shrink-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Automation label - always visible */}
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">Automation</span>
|
||||||
|
|
||||||
{/* Color indicator */}
|
{/* Color indicator */}
|
||||||
{color && (
|
{color && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export function AutomationLane({
|
|||||||
(time: number): number => {
|
(time: number): number => {
|
||||||
if (!containerRef.current) return 0;
|
if (!containerRef.current) return 0;
|
||||||
const width = containerRef.current.clientWidth;
|
const width = containerRef.current.clientWidth;
|
||||||
return (time / duration) * width * zoom;
|
return (time / duration) * width;
|
||||||
},
|
},
|
||||||
[duration, zoom]
|
[duration]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
|
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
|
||||||
@@ -58,9 +58,9 @@ export function AutomationLane({
|
|||||||
(x: number): number => {
|
(x: number): number => {
|
||||||
if (!containerRef.current) return 0;
|
if (!containerRef.current) return 0;
|
||||||
const width = containerRef.current.clientWidth;
|
const width = containerRef.current.clientWidth;
|
||||||
return (x / (width * zoom)) * duration;
|
return (x / width) * duration;
|
||||||
},
|
},
|
||||||
[duration, zoom]
|
[duration]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert Y pixel position to value (0-1)
|
// Convert Y pixel position to value (0-1)
|
||||||
@@ -209,7 +209,7 @@ export function AutomationLane({
|
|||||||
const width = rect.width;
|
const width = rect.width;
|
||||||
|
|
||||||
// Calculate new time and value
|
// Calculate new time and value
|
||||||
const timePerPixel = duration / (width * zoom);
|
const timePerPixel = duration / width;
|
||||||
const valuePerPixel = 1 / lane.height;
|
const valuePerPixel = 1 / lane.height;
|
||||||
|
|
||||||
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
|
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
|
||||||
@@ -217,7 +217,7 @@ export function AutomationLane({
|
|||||||
|
|
||||||
onUpdatePoint(pointId, { time: newTime, value: newValue });
|
onUpdatePoint(pointId, { time: newTime, value: newValue });
|
||||||
},
|
},
|
||||||
[lane.points, lane.height, duration, zoom, onUpdatePoint]
|
[lane.points, lane.height, duration, onUpdatePoint]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointDragEnd = React.useCallback(() => {
|
const handlePointDragEnd = React.useCallback(() => {
|
||||||
|
|||||||
@@ -4,20 +4,24 @@ import { useState } from 'react';
|
|||||||
import { ImportOptions } from '@/lib/audio/decoder';
|
import { ImportOptions } from '@/lib/audio/decoder';
|
||||||
|
|
||||||
export interface ImportDialogProps {
|
export interface ImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
onImport: (options: ImportOptions) => void;
|
onImport: (options: ImportOptions) => void;
|
||||||
onCancel: () => void;
|
fileName?: string;
|
||||||
fileName: string;
|
sampleRate?: number;
|
||||||
originalSampleRate?: number;
|
channels?: number;
|
||||||
originalChannels?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportDialog({
|
export function ImportDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
onImport,
|
onImport,
|
||||||
onCancel,
|
|
||||||
fileName,
|
fileName,
|
||||||
originalSampleRate,
|
sampleRate: originalSampleRate,
|
||||||
originalChannels,
|
channels: originalChannels,
|
||||||
}: ImportDialogProps) {
|
}: ImportDialogProps) {
|
||||||
|
// Don't render if not open
|
||||||
|
if (!open) return null;
|
||||||
const [options, setOptions] = useState<ImportOptions>({
|
const [options, setOptions] = useState<ImportOptions>({
|
||||||
convertToMono: false,
|
convertToMono: false,
|
||||||
targetSampleRate: undefined,
|
targetSampleRate: undefined,
|
||||||
@@ -134,7 +138,7 @@ export function ImportDialog({
|
|||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1569,7 +1569,7 @@ export function AudioEditor() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right Sidebar - Master Controls & Analyzers */}
|
{/* Right Sidebar - Master Controls & Analyzers */}
|
||||||
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col p-4 gap-4 w-[280px]">
|
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col pt-5 px-4 pb-4 gap-4 w-60">
|
||||||
{/* Master Controls */}
|
{/* Master Controls */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<MasterControls
|
<MasterControls
|
||||||
@@ -1655,7 +1655,7 @@ export function AudioEditor() {
|
|||||||
|
|
||||||
{/* Analyzer Display */}
|
{/* Analyzer Display */}
|
||||||
<div className="flex-1 min-h-[360px] flex items-start justify-center">
|
<div className="flex-1 min-h-[360px] flex items-start justify-center">
|
||||||
<div className="w-[192px]">
|
<div className="w-[178px]">
|
||||||
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
|
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
|
||||||
{analyzerView === 'spectrogram' && <Spectrogram analyserNode={masterAnalyser} />}
|
{analyzerView === 'spectrogram' && <Spectrogram analyserNode={masterAnalyser} />}
|
||||||
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}
|
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function PlaybackControls({
|
|||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<div className={cn('space-y-4 w-full max-w-2xl', className)}>
|
||||||
{/* Timeline Slider */}
|
{/* Timeline Slider */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
@@ -161,7 +161,7 @@ export function PlaybackControls({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transport Controls */}
|
{/* Transport Controls */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Circle, Headphones, MoreHorizontal } from 'lucide-react';
|
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import { CircularKnob } from '@/components/ui/CircularKnob';
|
import { CircularKnob } from '@/components/ui/CircularKnob';
|
||||||
import { TrackFader } from './TrackFader';
|
import { TrackFader } from './TrackFader';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface TrackControlsProps {
|
export interface TrackControlsProps {
|
||||||
|
trackName: string;
|
||||||
|
trackColor: string;
|
||||||
|
collapsed: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
pan: number;
|
pan: number;
|
||||||
peakLevel: number;
|
peakLevel: number;
|
||||||
@@ -17,6 +20,8 @@ export interface TrackControlsProps {
|
|||||||
showAutomation?: boolean;
|
showAutomation?: boolean;
|
||||||
showEffects?: boolean;
|
showEffects?: boolean;
|
||||||
isRecording?: boolean;
|
isRecording?: boolean;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
onVolumeChange: (volume: number) => void;
|
onVolumeChange: (volume: number) => void;
|
||||||
onPanChange: (pan: number) => void;
|
onPanChange: (pan: number) => void;
|
||||||
onMuteToggle: () => void;
|
onMuteToggle: () => void;
|
||||||
@@ -32,6 +37,9 @@ export interface TrackControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrackControls({
|
export function TrackControls({
|
||||||
|
trackName,
|
||||||
|
trackColor,
|
||||||
|
collapsed,
|
||||||
volume,
|
volume,
|
||||||
pan,
|
pan,
|
||||||
peakLevel,
|
peakLevel,
|
||||||
@@ -42,6 +50,8 @@ export function TrackControls({
|
|||||||
showAutomation = false,
|
showAutomation = false,
|
||||||
showEffects = false,
|
showEffects = false,
|
||||||
isRecording = false,
|
isRecording = false,
|
||||||
|
onNameChange,
|
||||||
|
onToggleCollapse,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
onPanChange,
|
onPanChange,
|
||||||
onMuteToggle,
|
onMuteToggle,
|
||||||
@@ -55,11 +65,77 @@ export function TrackControls({
|
|||||||
onPanTouchEnd,
|
onPanTouchEnd,
|
||||||
className,
|
className,
|
||||||
}: TrackControlsProps) {
|
}: TrackControlsProps) {
|
||||||
|
const [isEditingName, setIsEditingName] = React.useState(false);
|
||||||
|
const [editName, setEditName] = React.useState(trackName);
|
||||||
|
|
||||||
|
const handleNameClick = () => {
|
||||||
|
setIsEditingName(true);
|
||||||
|
setEditName(trackName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameBlur = () => {
|
||||||
|
setIsEditingName(false);
|
||||||
|
if (editName.trim() && editName !== trackName) {
|
||||||
|
onNameChange(editName.trim());
|
||||||
|
} else {
|
||||||
|
setEditName(trackName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleNameBlur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsEditingName(false);
|
||||||
|
setEditName(trackName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex flex-col items-center justify-between h-full px-3 pt-2 pb-4 bg-card/50 border-2 border-accent/50 rounded-lg',
|
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
|
{/* Track Name Header with Collapse Chevron */}
|
||||||
|
<div className="flex items-center gap-1 w-full">
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
|
||||||
|
title={collapsed ? 'Expand track' : 'Collapse track'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 flex items-center justify-center min-w-0">
|
||||||
|
{isEditingName ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
autoFocus
|
||||||
|
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center bg-transparent border-b focus:outline-none px-1"
|
||||||
|
style={{ color: trackColor, borderColor: trackColor }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={handleNameClick}
|
||||||
|
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center cursor-text hover:bg-accent/10 px-1 rounded transition-colors truncate"
|
||||||
|
style={{ color: trackColor }}
|
||||||
|
title="Click to edit track name"
|
||||||
|
>
|
||||||
|
{trackName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Spacer to balance the chevron and center the label */}
|
||||||
|
<div className="p-0.5 flex-shrink-0 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pan Control - Top */}
|
{/* Pan Control - Top */}
|
||||||
<div className="flex justify-center w-full">
|
<div className="flex justify-center w-full">
|
||||||
<CircularKnob
|
<CircularKnob
|
||||||
@@ -71,7 +147,7 @@ export function TrackControls({
|
|||||||
max={1}
|
max={1}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
label="PAN"
|
label="PAN"
|
||||||
size={40}
|
size={48}
|
||||||
formatValue={(value: number) => {
|
formatValue={(value: number) => {
|
||||||
if (Math.abs(value) < 0.01) return 'C';
|
if (Math.abs(value) < 0.01) return 'C';
|
||||||
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
|
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
|
||||||
@@ -94,14 +170,14 @@ export function TrackControls({
|
|||||||
|
|
||||||
{/* Control Buttons - Bottom */}
|
{/* Control Buttons - Bottom */}
|
||||||
<div className="flex flex-col gap-1 w-full">
|
<div className="flex flex-col gap-1 w-full">
|
||||||
{/* Control Buttons Row 1: R/S/M */}
|
{/* Control Buttons Row 1: R/M/S */}
|
||||||
<div className="flex items-center gap-0.5 w-full justify-center">
|
<div className="flex items-center gap-1 w-full justify-center">
|
||||||
{/* Record Arm */}
|
{/* Record Arm */}
|
||||||
{onRecordToggle && (
|
{onRecordToggle && (
|
||||||
<button
|
<button
|
||||||
onClick={onRecordToggle}
|
onClick={onRecordToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
|
||||||
isRecordEnabled
|
isRecordEnabled
|
||||||
? 'bg-red-500 text-white shadow-md shadow-red-500/30'
|
? 'bg-red-500 text-white shadow-md shadow-red-500/30'
|
||||||
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50',
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50',
|
||||||
@@ -109,23 +185,7 @@ export function TrackControls({
|
|||||||
)}
|
)}
|
||||||
title="Arm track for recording"
|
title="Arm track for recording"
|
||||||
>
|
>
|
||||||
<Circle className="h-2.5 w-2.5 fill-current" />
|
<Circle className="h-3 w-3 fill-current" />
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Solo Button */}
|
|
||||||
{onSoloToggle && (
|
|
||||||
<button
|
|
||||||
onClick={onSoloToggle}
|
|
||||||
className={cn(
|
|
||||||
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
|
|
||||||
isSolo
|
|
||||||
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
|
|
||||||
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
|
||||||
)}
|
|
||||||
title="Solo track"
|
|
||||||
>
|
|
||||||
<Headphones className="h-2.5 w-2.5" />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -133,7 +193,7 @@ export function TrackControls({
|
|||||||
<button
|
<button
|
||||||
onClick={onMuteToggle}
|
onClick={onMuteToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
|
||||||
isMuted
|
isMuted
|
||||||
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
|
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
|
||||||
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
@@ -142,39 +202,20 @@ export function TrackControls({
|
|||||||
>
|
>
|
||||||
M
|
M
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Control Buttons Row 2: A/E */}
|
{/* Solo Button */}
|
||||||
<div className="flex items-center gap-0.5 w-full justify-center">
|
{onSoloToggle && (
|
||||||
{/* Automation Toggle */}
|
|
||||||
{onAutomationToggle && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onAutomationToggle}
|
onClick={onSoloToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
|
||||||
showAutomation
|
isSolo
|
||||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/30'
|
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
|
||||||
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
)}
|
)}
|
||||||
title="Toggle automation"
|
title="Solo track"
|
||||||
>
|
>
|
||||||
A
|
<Headphones className="h-3 w-3" />
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Effects Button */}
|
|
||||||
{onEffectsClick && (
|
|
||||||
<button
|
|
||||||
onClick={onEffectsClick}
|
|
||||||
className={cn(
|
|
||||||
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
|
|
||||||
showEffects
|
|
||||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/30'
|
|
||||||
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
|
||||||
)}
|
|
||||||
title={showEffects ? 'Hide effects' : 'Show effects'}
|
|
||||||
>
|
|
||||||
E
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
222
components/tracks/TrackExtensions.tsx
Normal file
222
components/tracks/TrackExtensions.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Plus, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
|
||||||
|
import type { Track as TrackType } from '@/types/track';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { EffectDevice } from '@/components/effects/EffectDevice';
|
||||||
|
import { EffectBrowser } from '@/components/effects/EffectBrowser';
|
||||||
|
import type { EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
|
export interface TrackExtensionsProps {
|
||||||
|
track: TrackType;
|
||||||
|
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
||||||
|
onToggleEffect?: (effectId: string) => void;
|
||||||
|
onRemoveEffect?: (effectId: string) => void;
|
||||||
|
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
||||||
|
onAddEffect?: (effectType: EffectType) => void;
|
||||||
|
asOverlay?: boolean; // When true, renders as full overlay without header
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackExtensions({
|
||||||
|
track,
|
||||||
|
onUpdateTrack,
|
||||||
|
onToggleEffect,
|
||||||
|
onRemoveEffect,
|
||||||
|
onUpdateEffect,
|
||||||
|
onAddEffect,
|
||||||
|
asOverlay = false,
|
||||||
|
}: TrackExtensionsProps) {
|
||||||
|
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Don't render if track is collapsed (unless it's an overlay, which handles its own visibility)
|
||||||
|
if (!asOverlay && track.collapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay mode: render full-screen effect rack
|
||||||
|
if (asOverlay) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col h-full bg-card/95 rounded-lg border border-border shadow-2xl">
|
||||||
|
{/* Header with close button */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">Effects</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({track.effectChain.effects.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setEffectBrowserOpen(true)}
|
||||||
|
title="Add effect"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onUpdateTrack(track.id, { showEffects: false })}
|
||||||
|
title="Close effects"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effects rack */}
|
||||||
|
<div className="flex-1 overflow-x-auto custom-scrollbar p-4">
|
||||||
|
<div className="flex h-full gap-4">
|
||||||
|
{track.effectChain.effects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full text-center gap-3">
|
||||||
|
<Sparkles className="h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">No effects yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
Click + to add an effect
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
track.effectChain.effects.map((effect) => (
|
||||||
|
<EffectDevice
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
onToggleEnabled={() => onToggleEffect?.(effect.id)}
|
||||||
|
onRemove={() => onRemoveEffect?.(effect.id)}
|
||||||
|
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
|
||||||
|
onToggleExpanded={() => {
|
||||||
|
const updatedEffects = track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
effectChain: { ...track.effectChain, effects: updatedEffects },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effect Browser Dialog */}
|
||||||
|
<EffectBrowser
|
||||||
|
open={effectBrowserOpen}
|
||||||
|
onClose={() => setEffectBrowserOpen(false)}
|
||||||
|
onSelectEffect={(effectType) => {
|
||||||
|
if (onAddEffect) {
|
||||||
|
onAddEffect(effectType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original inline mode
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Effects Section (Collapsible, Full Width) */}
|
||||||
|
<div className="bg-muted/50 border-b border-border/50">
|
||||||
|
{/* Effects Header - clickable to toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-accent/30 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
showEffects: !track.showEffects,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.showEffects ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show mini effect chain when collapsed */}
|
||||||
|
{!track.showEffects && track.effectChain.effects.length > 0 ? (
|
||||||
|
<div className="flex-1 flex items-center gap-1 overflow-x-auto custom-scrollbar">
|
||||||
|
{track.effectChain.effects.map((effect) => (
|
||||||
|
<div
|
||||||
|
key={effect.id}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded text-[10px] font-medium flex-shrink-0',
|
||||||
|
effect.enabled
|
||||||
|
? 'bg-primary/20 text-primary border border-primary/30'
|
||||||
|
: 'bg-muted/30 text-muted-foreground border border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effect.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Devices ({track.effectChain.effects.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEffectBrowserOpen(true);
|
||||||
|
}}
|
||||||
|
title="Add effect"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal scrolling device rack - expanded state */}
|
||||||
|
{track.showEffects && (
|
||||||
|
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 p-3">
|
||||||
|
<div className="flex h-full gap-3">
|
||||||
|
{track.effectChain.effects.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8 w-full">
|
||||||
|
No devices. Click + to add an effect.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
track.effectChain.effects.map((effect) => (
|
||||||
|
<EffectDevice
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
onToggleEnabled={() => onToggleEffect?.(effect.id)}
|
||||||
|
onRemove={() => onRemoveEffect?.(effect.id)}
|
||||||
|
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
|
||||||
|
onToggleExpanded={() => {
|
||||||
|
const updatedEffects = track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
effectChain: { ...track.effectChain, effects: updatedEffects },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effect Browser Dialog */}
|
||||||
|
<EffectBrowser
|
||||||
|
open={effectBrowserOpen}
|
||||||
|
onClose={() => setEffectBrowserOpen(false)}
|
||||||
|
onSelectEffect={(effectType) => {
|
||||||
|
if (onAddEffect) {
|
||||||
|
onAddEffect(effectType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,9 +108,9 @@ export function TrackFader({
|
|||||||
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex gap-2', className)} style={{ marginLeft: '16px' }}>
|
<div className={cn('flex gap-3', className)} style={{ marginLeft: '16px' }}>
|
||||||
{/* dB Labels (Left) */}
|
{/* dB Labels (Left) */}
|
||||||
<div className="flex flex-col justify-between text-[9px] font-mono text-muted-foreground py-1">
|
<div className="flex flex-col justify-between text-[10px] font-mono text-muted-foreground py-1">
|
||||||
<span>0</span>
|
<span>0</span>
|
||||||
<span>-12</span>
|
<span>-12</span>
|
||||||
<span>-24</span>
|
<span>-24</span>
|
||||||
@@ -120,12 +120,12 @@ export function TrackFader({
|
|||||||
{/* Fader Container */}
|
{/* Fader Container */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-10 h-32 bg-background/50 rounded-md border border-border/50 cursor-pointer"
|
className="relative w-12 h-40 bg-background/50 rounded-md border border-border/50 cursor-pointer"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
{/* Peak Meter (Horizontal Bar - Top) */}
|
{/* 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 inset-x-2 top-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 transition-all duration-75 ease-out"
|
className="absolute left-0 top-0 bottom-0 transition-all duration-75 ease-out"
|
||||||
style={{ width: `${Math.max(0, Math.min(100, peakWidth))}%` }}
|
style={{ width: `${Math.max(0, Math.min(100, peakWidth))}%` }}
|
||||||
@@ -140,7 +140,7 @@ export function TrackFader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RMS Meter (Horizontal Bar - Bottom) */}
|
{/* 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 inset-x-2 bottom-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 transition-all duration-150 ease-out"
|
className="absolute left-0 top-0 bottom-0 transition-all duration-150 ease-out"
|
||||||
style={{ width: `${Math.max(0, Math.min(100, rmsWidth))}%` }}
|
style={{ width: `${Math.max(0, Math.min(100, rmsWidth))}%` }}
|
||||||
@@ -155,26 +155,26 @@ export function TrackFader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fader Track */}
|
{/* Fader Track */}
|
||||||
<div className="absolute top-6 bottom-6 left-1/2 -translate-x-1/2 w-1 bg-muted/50 rounded-full" />
|
<div className="absolute top-8 bottom-8 left-1/2 -translate-x-1/2 w-1.5 bg-muted/50 rounded-full" />
|
||||||
|
|
||||||
{/* Fader Handle */}
|
{/* Fader Handle */}
|
||||||
<div
|
<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"
|
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={{
|
style={{
|
||||||
// Inverted: value 1 = top, value 0 = bottom
|
// Inverted: value 1 = top, value 0 = bottom
|
||||||
top: `calc(${(1 - value) * 100}% - 0.4375rem)`,
|
top: `calc(${(1 - value) * 100}% - 0.5rem)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Handle grip lines */}
|
{/* Handle grip lines */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-0.5">
|
<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-2 w-px bg-primary-foreground/30" />
|
||||||
<div className="h-1.5 w-px bg-primary-foreground/30" />
|
<div className="h-2 w-px bg-primary-foreground/30" />
|
||||||
<div className="h-1.5 w-px bg-primary-foreground/30" />
|
<div className="h-2 w-px bg-primary-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* dB Scale Markers */}
|
{/* dB Scale Markers */}
|
||||||
<div className="absolute inset-0 px-1.5 py-6 pointer-events-none">
|
<div className="absolute inset-0 px-2 py-8 pointer-events-none">
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
{/* -12 dB */}
|
{/* -12 dB */}
|
||||||
<div className="absolute left-0 right-0 h-px bg-border/20" style={{ top: '50%' }} />
|
<div className="absolute left-0 right-0 h-px bg-border/20" style={{ top: '50%' }} />
|
||||||
@@ -186,17 +186,49 @@ export function TrackFader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Value Display (Right) */}
|
{/* Value and Level Display (Right) */}
|
||||||
<div className="flex flex-col justify-center items-start text-[9px] font-mono">
|
<div className="flex flex-col justify-between items-start text-[9px] font-mono py-1 w-[36px]">
|
||||||
{/* Current dB Value */}
|
{/* Current dB Value */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'font-bold text-[10px] w-[32px]',
|
'font-bold text-[11px]',
|
||||||
valueDb > -3 ? 'text-red-500' :
|
valueDb > -3 ? 'text-red-500' :
|
||||||
valueDb > -6 ? 'text-yellow-500' :
|
valueDb > -6 ? 'text-yellow-500' :
|
||||||
'text-green-500'
|
'text-green-500'
|
||||||
)}>
|
)}>
|
||||||
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
|
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Plus, Upload } from 'lucide-react';
|
import { Plus, Upload, ChevronDown, ChevronRight, ChevronUp, X, Eye, EyeOff } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { Track } from './Track';
|
import { Track } from './Track';
|
||||||
|
import { TrackExtensions } from './TrackExtensions';
|
||||||
import { ImportTrackDialog } from './ImportTrackDialog';
|
import { ImportTrackDialog } from './ImportTrackDialog';
|
||||||
import type { Track as TrackType } from '@/types/track';
|
import type { Track as TrackType } from '@/types/track';
|
||||||
|
import { DEFAULT_TRACK_HEIGHT, COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT } from '@/types/track';
|
||||||
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||||
|
import { AutomationLane } from '@/components/automation/AutomationLane';
|
||||||
|
import { AutomationHeader } from '@/components/automation/AutomationHeader';
|
||||||
|
import type { AutomationPoint as AutomationPointType } from '@/types/automation';
|
||||||
|
import { createAutomationPoint } from '@/lib/audio/automation/utils';
|
||||||
|
import { EffectDevice } from '@/components/effects/EffectDevice';
|
||||||
|
|
||||||
export interface TrackListProps {
|
export interface TrackListProps {
|
||||||
tracks: TrackType[];
|
tracks: TrackType[];
|
||||||
@@ -50,6 +58,75 @@ export function TrackList({
|
|||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
|
const waveformScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const controlsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Refs for horizontal scroll synchronization (per track)
|
||||||
|
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const [syncingScroll, setSyncingScroll] = React.useState(false);
|
||||||
|
|
||||||
|
// Synchronize vertical scroll between controls and waveforms
|
||||||
|
const handleWaveformScroll = React.useCallback(() => {
|
||||||
|
if (waveformScrollRef.current && controlsScrollRef.current) {
|
||||||
|
controlsScrollRef.current.scrollTop = waveformScrollRef.current.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Synchronize horizontal scroll across all tracks (waveforms and automation lanes)
|
||||||
|
const handleWaveformHScroll = React.useCallback((trackId: string) => {
|
||||||
|
if (syncingScroll) return;
|
||||||
|
setSyncingScroll(true);
|
||||||
|
|
||||||
|
const sourceEl = waveformHScrollRefs.current.get(trackId);
|
||||||
|
if (!sourceEl) {
|
||||||
|
setSyncingScroll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollLeft = sourceEl.scrollLeft;
|
||||||
|
|
||||||
|
// Sync all waveforms
|
||||||
|
waveformHScrollRefs.current.forEach((el, id) => {
|
||||||
|
if (id !== trackId) {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync all automation lanes
|
||||||
|
automationHScrollRefs.current.forEach((el) => {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSyncingScroll(false);
|
||||||
|
}, [syncingScroll]);
|
||||||
|
|
||||||
|
const handleAutomationHScroll = React.useCallback((trackId: string) => {
|
||||||
|
if (syncingScroll) return;
|
||||||
|
setSyncingScroll(true);
|
||||||
|
|
||||||
|
const sourceEl = automationHScrollRefs.current.get(trackId);
|
||||||
|
if (!sourceEl) {
|
||||||
|
setSyncingScroll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollLeft = sourceEl.scrollLeft;
|
||||||
|
|
||||||
|
// Sync all waveforms
|
||||||
|
waveformHScrollRefs.current.forEach((el) => {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync all automation lanes
|
||||||
|
automationHScrollRefs.current.forEach((el, id) => {
|
||||||
|
if (id !== trackId) {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSyncingScroll(false);
|
||||||
|
}, [syncingScroll]);
|
||||||
|
|
||||||
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||||
if (onImportTrack) {
|
if (onImportTrack) {
|
||||||
@@ -88,11 +165,14 @@ export function TrackList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Track List */}
|
{/* Track List - Two Column Layout */}
|
||||||
<div className="flex-1 overflow-auto custom-scrollbar">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
|
||||||
|
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
|
||||||
{tracks.map((track) => (
|
{tracks.map((track) => (
|
||||||
|
<React.Fragment key={track.id}>
|
||||||
|
{/* Track Controls */}
|
||||||
<Track
|
<Track
|
||||||
key={track.id}
|
|
||||||
track={track}
|
track={track}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -174,9 +254,488 @@ export function TrackList({
|
|||||||
playbackLevel={trackLevels[track.id] || 0}
|
playbackLevel={trackLevels[track.id] || 0}
|
||||||
onParameterTouched={onParameterTouched}
|
onParameterTouched={onParameterTouched}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
|
renderControlsOnly={true}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Waveforms (Flexible Width, Vertical Scroll Only) */}
|
||||||
|
<div
|
||||||
|
ref={waveformScrollRef}
|
||||||
|
onScroll={handleWaveformScroll}
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<React.Fragment key={track.id}>
|
||||||
|
{/* Track Waveform Row with bars stacked below - Fixed height container */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Waveform - Takes remaining space, horizontally scrollable */}
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{/* Upload hint for empty tracks - stays fixed as overlay */}
|
||||||
|
{!track.audioBuffer && !track.collapsed && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-sm text-muted-foreground pointer-events-none z-10">
|
||||||
|
<Upload className="h-6 w-6 mb-2 opacity-50" />
|
||||||
|
<p>Click waveform area to load audio</p>
|
||||||
|
<p className="text-xs opacity-75 mt-1">or drag & drop</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) waveformHScrollRefs.current.set(track.id, el);
|
||||||
|
}}
|
||||||
|
onScroll={() => handleWaveformHScroll(track.id)}
|
||||||
|
className="w-full h-full overflow-x-auto custom-scrollbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Track
|
||||||
|
track={track}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
isSelected={selectedTrackId === track.id}
|
||||||
|
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
|
||||||
|
onToggleMute={() =>
|
||||||
|
onUpdateTrack(track.id, { mute: !track.mute })
|
||||||
|
}
|
||||||
|
onToggleSolo={() =>
|
||||||
|
onUpdateTrack(track.id, { solo: !track.solo })
|
||||||
|
}
|
||||||
|
onToggleCollapse={() =>
|
||||||
|
onUpdateTrack(track.id, { collapsed: !track.collapsed })
|
||||||
|
}
|
||||||
|
onVolumeChange={(volume) =>
|
||||||
|
onUpdateTrack(track.id, { volume })
|
||||||
|
}
|
||||||
|
onPanChange={(pan) =>
|
||||||
|
onUpdateTrack(track.id, { pan })
|
||||||
|
}
|
||||||
|
onRemove={() => onRemoveTrack(track.id)}
|
||||||
|
onNameChange={(name) =>
|
||||||
|
onUpdateTrack(track.id, { name })
|
||||||
|
}
|
||||||
|
onUpdateTrack={onUpdateTrack}
|
||||||
|
onSeek={onSeek}
|
||||||
|
onLoadAudio={(buffer) =>
|
||||||
|
onUpdateTrack(track.id, { audioBuffer: buffer })
|
||||||
|
}
|
||||||
|
onToggleEffect={(effectId) => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effectId ? { ...e, enabled: !e.enabled } : e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onRemoveEffect={(effectId) => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onUpdateEffect={(effectId, parameters) => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effectId ? { ...e, parameters } : e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onAddEffect={(effectType) => {
|
||||||
|
const newEffect = createEffect(
|
||||||
|
effectType,
|
||||||
|
EFFECT_NAMES[effectType]
|
||||||
|
);
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: [...track.effectChain.effects, newEffect],
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onSelectionChange={
|
||||||
|
onSelectionChange
|
||||||
|
? (selection) => onSelectionChange(track.id, selection)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onToggleRecordEnable={
|
||||||
|
onToggleRecordEnable
|
||||||
|
? () => onToggleRecordEnable(track.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isRecording={recordingTrackId === track.id}
|
||||||
|
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
|
||||||
|
playbackLevel={trackLevels[track.id] || 0}
|
||||||
|
onParameterTouched={onParameterTouched}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
renderWaveformOnly={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Automation Bar - Collapsible - Fixed height when expanded */}
|
||||||
|
{!track.collapsed && (() => {
|
||||||
|
const selectedParam = track.automation.selectedParameterId || 'volume';
|
||||||
|
const currentLane = track.automation.lanes.find(
|
||||||
|
l => l.parameterId === selectedParam
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build available parameters list
|
||||||
|
const availableParameters: Array<{ id: string; name: string }> = [
|
||||||
|
{ id: 'volume', name: 'Volume' },
|
||||||
|
{ id: 'pan', name: 'Pan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add effect parameters
|
||||||
|
track.effectChain.effects.forEach((effect) => {
|
||||||
|
if (effect.parameters) {
|
||||||
|
Object.keys(effect.parameters).forEach((paramKey) => {
|
||||||
|
const parameterId = `effect.${effect.id}.${paramKey}`;
|
||||||
|
const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`;
|
||||||
|
availableParameters.push({ id: parameterId, name: paramName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get parameters that have automation lanes with points
|
||||||
|
const automatedParams = track.automation.lanes
|
||||||
|
.filter(lane => lane.points.length > 0)
|
||||||
|
.map(lane => {
|
||||||
|
const param = availableParameters.find(p => p.id === lane.parameterId);
|
||||||
|
return param ? param.name : lane.parameterName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const modes = ['read', 'write', 'touch', 'latch'] as const;
|
||||||
|
const MODE_LABELS = { read: 'R', write: 'W', touch: 'T', latch: 'L' };
|
||||||
|
const MODE_COLORS = {
|
||||||
|
read: 'text-muted-foreground',
|
||||||
|
write: 'text-red-500',
|
||||||
|
touch: 'text-yellow-500',
|
||||||
|
latch: 'text-orange-500',
|
||||||
|
};
|
||||||
|
const currentModeIndex = modes.indexOf(currentLane?.mode || 'read');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm">
|
||||||
|
{/* Automation Header - Single Bar */}
|
||||||
|
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30">
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">Automation</span>
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
{currentLane?.color && (
|
||||||
|
<div
|
||||||
|
className="w-1 h-4 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: currentLane.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parameter labels - always visible */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
|
||||||
|
{automatedParams.map((paramName, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0 bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
|
{paramName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls - only visible when expanded */}
|
||||||
|
{track.automationExpanded && (
|
||||||
|
<>
|
||||||
|
{/* Parameter selector */}
|
||||||
|
{availableParameters && availableParameters.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={selectedParam}
|
||||||
|
onChange={(e) => onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, selectedParameterId: e.target.value },
|
||||||
|
})}
|
||||||
|
className="text-xs font-medium text-foreground w-auto min-w-[120px] max-w-[200px] bg-background/50 border border-border/30 rounded px-1.5 py-0.5 hover:bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary flex-shrink-0"
|
||||||
|
>
|
||||||
|
{availableParameters.map((param) => (
|
||||||
|
<option key={param.id} value={param.id}>
|
||||||
|
{param.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Automation mode button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const nextIndex = (currentModeIndex + 1) % modes.length;
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, mode: modes[nextIndex] } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={`Automation mode: ${currentLane?.mode || 'read'} (click to cycle)`}
|
||||||
|
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[currentLane?.mode || 'read'])}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[currentLane?.mode || 'read']}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Height controls */}
|
||||||
|
<div className="flex flex-col gap-0 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const newHeight = Math.max(60, Math.min(200, currentLane.height + 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Increase lane height"
|
||||||
|
className="h-3 w-4 p-0"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const newHeight = Math.max(60, Math.min(200, currentLane.height - 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Decrease lane height"
|
||||||
|
className="h-3 w-4 p-0"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show/hide toggle - Positioned absolutely on the right */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, { automationExpanded: !track.automationExpanded });
|
||||||
|
}}
|
||||||
|
title={track.automationExpanded ? 'Hide automation controls' : 'Show automation controls'}
|
||||||
|
className="absolute right-2 h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{track.automationExpanded ? (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Automation Lane Content - Shown when expanded */}
|
||||||
|
{track.automationExpanded && (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) automationHScrollRefs.current.set(track.id, el);
|
||||||
|
}}
|
||||||
|
onScroll={() => handleAutomationHScroll(track.id)}
|
||||||
|
className="overflow-x-auto custom-scrollbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.automation.lanes
|
||||||
|
.filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible)
|
||||||
|
.map((lane) => (
|
||||||
|
<AutomationLane
|
||||||
|
key={lane.id}
|
||||||
|
lane={lane}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onAddPoint={(time, value) => {
|
||||||
|
const newPoint = createAutomationPoint({ time, value, curve: 'linear' });
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === lane.id
|
||||||
|
? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) }
|
||||||
|
: l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUpdatePoint={(pointId, updates) => {
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === lane.id
|
||||||
|
? {
|
||||||
|
...l,
|
||||||
|
points: l.points.map((p) =>
|
||||||
|
p.id === pointId ? { ...p, ...updates } : p
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRemovePoint={(pointId) => {
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === lane.id
|
||||||
|
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
|
||||||
|
: l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUpdateLane={(updates) => {
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === lane.id ? { ...l, ...updates } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Effects Bar - Collapsible - Fixed height when expanded */}
|
||||||
|
{!track.collapsed && (
|
||||||
|
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm border-b border-border">
|
||||||
|
{/* Effects Header - Collapsible */}
|
||||||
|
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-t border-b border-border/30 overflow-x-auto">
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">Effects</span>
|
||||||
|
|
||||||
|
{/* Effect name labels */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
{track.effectChain.effects.map((effect) => (
|
||||||
|
<span
|
||||||
|
key={effect.id}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0",
|
||||||
|
effect.enabled
|
||||||
|
? "bg-primary/10 text-primary border border-primary/20"
|
||||||
|
: "bg-muted/30 text-muted-foreground border border-border/30 opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effect.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show/hide toggle - Positioned absolutely on the right */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, { effectsExpanded: !track.effectsExpanded });
|
||||||
|
}}
|
||||||
|
title={track.effectsExpanded ? 'Hide effects' : 'Show effects'}
|
||||||
|
className="absolute right-2 h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{track.effectsExpanded ? (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effects Content - Collapsible, horizontally scrollable */}
|
||||||
|
{track.effectsExpanded && (
|
||||||
|
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 border-t border-border">
|
||||||
|
<div className="flex h-full gap-3 p-3">
|
||||||
|
{track.effectChain.effects.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8 w-full">
|
||||||
|
No effects. Click + to add an effect.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
track.effectChain.effects.map((effect) => (
|
||||||
|
<EffectDevice
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
onToggleEnabled={() => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effect.id ? { ...e, enabled: !e.enabled } : e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.filter((e) => e.id !== effect.id),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onUpdateParameters={(params) => {
|
||||||
|
const updatedChain = {
|
||||||
|
...track.effectChain,
|
||||||
|
effects: track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effect.id ? { ...e, parameters: params } : e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||||
|
}}
|
||||||
|
onToggleExpanded={() => {
|
||||||
|
const updatedEffects = track.effectChain.effects.map((e) =>
|
||||||
|
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
effectChain: { ...track.effectChain, effects: updatedEffects },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Import Dialog */}
|
{/* Import Dialog */}
|
||||||
{onImportTrack && (
|
{onImportTrack && (
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface Track {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
showEffects: boolean; // Show/hide per-track effects panel
|
showEffects: boolean; // Show/hide per-track effects panel
|
||||||
|
effectsExpanded?: boolean; // Whether effects bar is expanded (when showEffects is true)
|
||||||
|
automationExpanded?: boolean; // Whether automation bar is expanded (shows full controls)
|
||||||
|
|
||||||
// Selection (for editing operations)
|
// Selection (for editing operations)
|
||||||
selection: Selection | null;
|
selection: Selection | null;
|
||||||
@@ -68,7 +70,7 @@ export const TRACK_COLORS: Record<TrackColor, string> = {
|
|||||||
gray: 'rgb(156, 163, 175)',
|
gray: 'rgb(156, 163, 175)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_TRACK_HEIGHT = 340; // Knob + fader with labels + R/S/M/A/E buttons
|
export const DEFAULT_TRACK_HEIGHT = 400; // Knob + fader with labels + R/S/M/A/E buttons
|
||||||
export const MIN_TRACK_HEIGHT = 240; // Minimum to fit knob + fader with labels + all buttons
|
export const MIN_TRACK_HEIGHT = 400; // Minimum to fit knob + fader with labels + all buttons
|
||||||
export const MAX_TRACK_HEIGHT = 500; // Increased for better waveform viewing
|
export const MAX_TRACK_HEIGHT = 500; // Increased for better waveform viewing
|
||||||
export const COLLAPSED_TRACK_HEIGHT = 48; // Extracted constant for collapsed state
|
export const COLLAPSED_TRACK_HEIGHT = 48; // Extracted constant for collapsed state
|
||||||
|
|||||||
Reference in New Issue
Block a user