From cb396ddfd67d4436b647a8dd69352647ba08e255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Tue, 18 Nov 2025 08:02:21 +0100 Subject: [PATCH] feat: add EffectBrowser dialog for adding effects to tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/effects/EffectBrowser.tsx | 122 +++++++++++++++++++++++++++ components/tracks/Track.tsx | 21 ++++- components/tracks/TrackList.tsx | 12 +++ 3 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 components/effects/EffectBrowser.tsx diff --git a/components/effects/EffectBrowser.tsx b/components/effects/EffectBrowser.tsx new file mode 100644 index 0000000..a4a7fa8 --- /dev/null +++ b/components/effects/EffectBrowser.tsx @@ -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(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 = {}; + + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Add Effect

+ +
+ + {/* Search */} +
+
+ + 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 + /> +
+
+ + {/* Content */} +
+
+ {Object.entries(filteredCategories).map(([category, effects]) => ( +
+

+ {category} +

+
+ {effects.map((effect) => ( + + ))} +
+
+ ))} +
+ + {Object.keys(filteredCategories).length === 0 && ( +
+ No effects found matching "{search}" +
+ )} +
+
+
+ ); +} diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 3487918..e0af9ee 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -6,6 +6,8 @@ import type { Track as TrackType } from '@/types/track'; import { Button } from '@/components/ui/Button'; import { Slider } from '@/components/ui/Slider'; import { cn } from '@/lib/utils/cn'; +import { EffectBrowser } from '@/components/effects/EffectBrowser'; +import { createEffect, type EffectType } from '@/lib/audio/effects/chain'; export interface TrackProps { track: TrackType; @@ -25,6 +27,7 @@ export interface TrackProps { onLoadAudio?: (buffer: AudioBuffer) => void; onToggleEffect?: (effectId: string) => void; onRemoveEffect?: (effectId: string) => void; + onAddEffect?: (effectType: EffectType) => void; } export function Track({ @@ -45,6 +48,7 @@ export function Track({ onLoadAudio, onToggleEffect, onRemoveEffect, + onAddEffect, }: TrackProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); @@ -52,6 +56,7 @@ export function Track({ const [isEditingName, setIsEditingName] = React.useState(false); const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track')); const [showDevices, setShowDevices] = React.useState(true); + const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false); const inputRef = React.useRef(null); const handleNameClick = () => { @@ -401,10 +406,7 @@ export function Track({