feat: add EffectBrowser dialog for adding effects to tracks
Created a beautiful effect browser dialog inspired by Ableton Live: **EffectBrowser Component:** - Modal dialog with search functionality - Effects organized by category: - Dynamics (Compressor, Limiter, Gate) - Filters (Lowpass, Highpass, Bandpass, etc.) - Time-Based (Delay, Reverb, Chorus, Flanger, Phaser) - Distortion (Distortion, Bitcrusher) - Pitch & Time (Pitch Shifter, Time Stretch) - Utility (Normalize, Fade In/Out, Reverse) - Grid layout with hover effects - Real-time search filtering - Click effect to add to track **Integration:** - "+" button in track strip opens EffectBrowser dialog - Selecting an effect adds it to the track's effect chain - Effects appear immediately in the Devices section - Full enable/disable and remove functionality **UX Flow:** 1. Click "+" in track Devices section 2. Browse or search for effect 3. Click effect to add it 4. Effect appears in Devices list 5. Toggle on/off with ●/○ 6. Remove with × button Effects are now fully manageable in the UI! Next: Apply them to audio. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
122
components/effects/EffectBrowser.tsx
Normal file
122
components/effects/EffectBrowser.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X, Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
import { EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
|
export interface EffectBrowserProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectEffect: (effectType: EffectType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFECT_CATEGORIES = {
|
||||||
|
'Dynamics': ['compressor', 'limiter', 'gate'] as EffectType[],
|
||||||
|
'Filters': ['lowpass', 'highpass', 'bandpass', 'notch', 'lowshelf', 'highshelf', 'peaking'] as EffectType[],
|
||||||
|
'Time-Based': ['delay', 'reverb', 'chorus', 'flanger', 'phaser'] as EffectType[],
|
||||||
|
'Distortion': ['distortion', 'bitcrusher'] as EffectType[],
|
||||||
|
'Pitch & Time': ['pitch', 'timestretch'] as EffectType[],
|
||||||
|
'Utility': ['normalize', 'fadeIn', 'fadeOut', 'reverse'] as EffectType[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserProps) {
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSelectEffect = (effectType: EffectType) => {
|
||||||
|
onSelectEffect(effectType);
|
||||||
|
onClose();
|
||||||
|
setSearch('');
|
||||||
|
setSelectedCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCategories = React.useMemo(() => {
|
||||||
|
if (!search) return EFFECT_CATEGORIES;
|
||||||
|
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
const filtered: Record<string, EffectType[]> = {};
|
||||||
|
|
||||||
|
Object.entries(EFFECT_CATEGORIES).forEach(([category, effects]) => {
|
||||||
|
const matchingEffects = effects.filter((effect) =>
|
||||||
|
EFFECT_NAMES[effect].toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (matchingEffects.length > 0) {
|
||||||
|
filtered[category] = matchingEffects;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Add Effect</h2>
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search effects..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(filteredCategories).map(([category, effects]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-2">
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{effects.map((effect) => (
|
||||||
|
<button
|
||||||
|
key={effect}
|
||||||
|
onClick={() => handleSelectEffect(effect)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 text-left rounded-md border transition-colors',
|
||||||
|
'hover:bg-accent hover:border-primary',
|
||||||
|
'border-border bg-card text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{EFFECT_NAMES[effect]}</div>
|
||||||
|
<div className="text-xs text-muted-foreground capitalize">{effect}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(filteredCategories).length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No effects found matching "{search}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import type { Track as TrackType } from '@/types/track';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Slider } from '@/components/ui/Slider';
|
import { Slider } from '@/components/ui/Slider';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { EffectBrowser } from '@/components/effects/EffectBrowser';
|
||||||
|
import { createEffect, type EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
export interface TrackProps {
|
export interface TrackProps {
|
||||||
track: TrackType;
|
track: TrackType;
|
||||||
@@ -25,6 +27,7 @@ export interface TrackProps {
|
|||||||
onLoadAudio?: (buffer: AudioBuffer) => void;
|
onLoadAudio?: (buffer: AudioBuffer) => void;
|
||||||
onToggleEffect?: (effectId: string) => void;
|
onToggleEffect?: (effectId: string) => void;
|
||||||
onRemoveEffect?: (effectId: string) => void;
|
onRemoveEffect?: (effectId: string) => void;
|
||||||
|
onAddEffect?: (effectType: EffectType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Track({
|
export function Track({
|
||||||
@@ -45,6 +48,7 @@ export function Track({
|
|||||||
onLoadAudio,
|
onLoadAudio,
|
||||||
onToggleEffect,
|
onToggleEffect,
|
||||||
onRemoveEffect,
|
onRemoveEffect,
|
||||||
|
onAddEffect,
|
||||||
}: TrackProps) {
|
}: TrackProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -52,6 +56,7 @@ export function Track({
|
|||||||
const [isEditingName, setIsEditingName] = React.useState(false);
|
const [isEditingName, setIsEditingName] = React.useState(false);
|
||||||
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
|
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
|
||||||
const [showDevices, setShowDevices] = React.useState(true);
|
const [showDevices, setShowDevices] = React.useState(true);
|
||||||
|
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleNameClick = () => {
|
const handleNameClick = () => {
|
||||||
@@ -401,10 +406,7 @@ export function Track({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => {
|
onClick={() => setEffectBrowserOpen(true)}
|
||||||
// TODO: Open effect browser/selector dialog
|
|
||||||
console.log('Add effect clicked for track:', track.id);
|
|
||||||
}}
|
|
||||||
title="Add effect"
|
title="Add effect"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
>
|
>
|
||||||
@@ -498,6 +500,17 @@ export function Track({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Effect Browser Dialog */}
|
||||||
|
<EffectBrowser
|
||||||
|
open={effectBrowserOpen}
|
||||||
|
onClose={() => setEffectBrowserOpen(false)}
|
||||||
|
onSelectEffect={(effectType) => {
|
||||||
|
if (onAddEffect) {
|
||||||
|
onAddEffect(effectType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Track } from './Track';
|
import { Track } from './Track';
|
||||||
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 { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
export interface TrackListProps {
|
export interface TrackListProps {
|
||||||
tracks: TrackType[];
|
tracks: TrackType[];
|
||||||
@@ -123,6 +124,17 @@ export function TrackList({
|
|||||||
};
|
};
|
||||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
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 });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user