|
| 1 | +/** |
| 2 | + * WAMPluginBrowser — Browse and load WAM 2.0 plugins. |
| 3 | + * |
| 4 | + * Displays the curated WAM plugin catalog with search, category filtering, |
| 5 | + * and the ability to load plugins from custom URLs. |
| 6 | + */ |
| 7 | +import { useCallback, useMemo, useState } from 'react'; |
| 8 | +import { useWAMStore } from '../../store/wamStore'; |
| 9 | +import type { WAMCatalogEntry } from '../../types/wam'; |
| 10 | + |
| 11 | +// ── Inline icons ────────────────────────────────────────────────────────────── |
| 12 | +const SearchIcon = ({ className }: { className?: string }) => ( |
| 13 | + <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> |
| 14 | + <circle cx="11" cy="11" r="7" /> |
| 15 | + <path strokeLinecap="round" d="M21 21l-4.35-4.35" /> |
| 16 | + </svg> |
| 17 | +); |
| 18 | +const LinkIcon = ({ className }: { className?: string }) => ( |
| 19 | + <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> |
| 20 | + <path strokeLinecap="round" strokeLinejoin="round" d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> |
| 21 | + <path strokeLinecap="round" strokeLinejoin="round" d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> |
| 22 | + </svg> |
| 23 | +); |
| 24 | + |
| 25 | +type CategoryFilter = 'all' | 'instrument' | 'effect'; |
| 26 | + |
| 27 | +interface WAMPluginBrowserProps { |
| 28 | + /** Called when the user loads a plugin — receives the selected WAMCatalogEntry */ |
| 29 | + onLoadPlugin?: (entry: WAMCatalogEntry) => void; |
| 30 | +} |
| 31 | + |
| 32 | +export function WAMPluginBrowser({ onLoadPlugin }: WAMPluginBrowserProps) { |
| 33 | + const hostStatus = useWAMStore((s) => s.hostStatus); |
| 34 | + const searchCatalog = useWAMStore((s) => s.searchCatalog); |
| 35 | + |
| 36 | + const [search, setSearch] = useState(''); |
| 37 | + const [category, setCategory] = useState<CategoryFilter>('all'); |
| 38 | + const [showUrlInput, setShowUrlInput] = useState(false); |
| 39 | + const [customUrl, setCustomUrl] = useState(''); |
| 40 | + |
| 41 | + const filtered = useMemo(() => { |
| 42 | + const cat = category === 'all' ? undefined : category; |
| 43 | + return searchCatalog(search, cat); |
| 44 | + }, [search, category, searchCatalog]); |
| 45 | + |
| 46 | + const handleLoad = useCallback( |
| 47 | + (entry: WAMCatalogEntry) => onLoadPlugin?.(entry), |
| 48 | + [onLoadPlugin], |
| 49 | + ); |
| 50 | + |
| 51 | + const handleLoadCustomUrl = useCallback(() => { |
| 52 | + if (!customUrl.trim()) return; |
| 53 | + const entry: WAMCatalogEntry = { |
| 54 | + id: `custom-${Date.now()}`, |
| 55 | + name: 'Custom WAM Plugin', |
| 56 | + vendor: 'Custom', |
| 57 | + description: 'Plugin loaded from URL', |
| 58 | + category: 'effect', |
| 59 | + subcategory: 'custom', |
| 60 | + url: customUrl.trim(), |
| 61 | + tags: [], |
| 62 | + }; |
| 63 | + onLoadPlugin?.(entry); |
| 64 | + setCustomUrl(''); |
| 65 | + setShowUrlInput(false); |
| 66 | + }, [customUrl, onLoadPlugin]); |
| 67 | + |
| 68 | + // ── Not ready state ───────────────────────────────────── |
| 69 | + if (hostStatus !== 'ready') { |
| 70 | + return ( |
| 71 | + <div |
| 72 | + className="flex flex-col items-center justify-center gap-2 p-4 text-center" |
| 73 | + data-testid="wam-browser-not-ready" |
| 74 | + > |
| 75 | + <span className="text-[11px] text-zinc-400"> |
| 76 | + {hostStatus === 'initializing' |
| 77 | + ? 'Initializing WAM host...' |
| 78 | + : hostStatus === 'error' |
| 79 | + ? 'WAM host initialization failed' |
| 80 | + : 'WAM host not initialized'} |
| 81 | + </span> |
| 82 | + {hostStatus === 'idle' && ( |
| 83 | + <span className="text-[10px] text-zinc-500"> |
| 84 | + Start playback to initialize the audio engine |
| 85 | + </span> |
| 86 | + )} |
| 87 | + </div> |
| 88 | + ); |
| 89 | + } |
| 90 | + |
| 91 | + return ( |
| 92 | + <div className="flex flex-col gap-2 p-2" data-testid="wam-plugin-browser"> |
| 93 | + {/* Search + URL load */} |
| 94 | + <div className="flex items-center gap-2"> |
| 95 | + <div className="relative flex-1"> |
| 96 | + <SearchIcon className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-500" /> |
| 97 | + <input |
| 98 | + type="text" |
| 99 | + placeholder="Search WAM plugins..." |
| 100 | + value={search} |
| 101 | + onChange={(e) => setSearch(e.target.value)} |
| 102 | + aria-label="Search WAM plugins" |
| 103 | + data-testid="wam-search" |
| 104 | + className="h-7 w-full rounded-md border border-white/10 bg-white/5 pl-7 pr-2 text-xs text-white placeholder-zinc-500 outline-none focus:border-violet-500" |
| 105 | + /> |
| 106 | + </div> |
| 107 | + <button |
| 108 | + onClick={() => setShowUrlInput(!showUrlInput)} |
| 109 | + title="Load from URL" |
| 110 | + aria-label="Load plugin from URL" |
| 111 | + data-testid="wam-url-toggle" |
| 112 | + className={`flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] transition-colors ${ |
| 113 | + showUrlInput |
| 114 | + ? 'border-violet-500/40 bg-violet-500/10 text-violet-300' |
| 115 | + : 'border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10' |
| 116 | + }`} |
| 117 | + > |
| 118 | + <LinkIcon className="h-3 w-3" /> |
| 119 | + URL |
| 120 | + </button> |
| 121 | + </div> |
| 122 | + |
| 123 | + {/* Custom URL input */} |
| 124 | + {showUrlInput && ( |
| 125 | + <div className="flex gap-1" data-testid="wam-url-input-section"> |
| 126 | + <input |
| 127 | + type="url" |
| 128 | + placeholder="https://example.com/plugin/index.js" |
| 129 | + value={customUrl} |
| 130 | + onChange={(e) => setCustomUrl(e.target.value)} |
| 131 | + onKeyDown={(e) => e.key === 'Enter' && handleLoadCustomUrl()} |
| 132 | + aria-label="WAM plugin URL" |
| 133 | + data-testid="wam-url-input" |
| 134 | + className="flex-1 h-7 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder-zinc-500 outline-none focus:border-violet-500" |
| 135 | + /> |
| 136 | + <button |
| 137 | + onClick={handleLoadCustomUrl} |
| 138 | + disabled={!customUrl.trim()} |
| 139 | + data-testid="wam-url-load-btn" |
| 140 | + className="rounded-md bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500 disabled:opacity-40" |
| 141 | + > |
| 142 | + Load |
| 143 | + </button> |
| 144 | + </div> |
| 145 | + )} |
| 146 | + |
| 147 | + {/* Category tabs */} |
| 148 | + <div className="flex gap-1" role="tablist" aria-label="WAM plugin category filter"> |
| 149 | + {(['all', 'instrument', 'effect'] as const).map((cat) => ( |
| 150 | + <button |
| 151 | + key={cat} |
| 152 | + role="tab" |
| 153 | + aria-selected={category === cat} |
| 154 | + onClick={() => setCategory(cat)} |
| 155 | + data-testid={`wam-category-tab-${cat}`} |
| 156 | + className={`rounded-md px-2 py-0.5 text-[10px] font-medium capitalize transition-colors ${ |
| 157 | + category === cat |
| 158 | + ? 'bg-violet-600 text-white' |
| 159 | + : 'bg-white/5 text-zinc-400 hover:bg-white/10' |
| 160 | + }`} |
| 161 | + > |
| 162 | + {cat === 'all' ? 'All' : cat === 'instrument' ? 'Instruments' : 'Effects'} |
| 163 | + </button> |
| 164 | + ))} |
| 165 | + </div> |
| 166 | + |
| 167 | + {/* Plugin list */} |
| 168 | + {filtered.length === 0 ? ( |
| 169 | + <div className="py-4 text-center text-[11px] text-zinc-500" data-testid="wam-browser-empty"> |
| 170 | + No WAM plugins found. |
| 171 | + </div> |
| 172 | + ) : ( |
| 173 | + <div className="flex flex-col overflow-y-auto max-h-[300px]" data-testid="wam-plugin-list"> |
| 174 | + {filtered.map((entry) => ( |
| 175 | + <WAMPluginRow key={entry.id} entry={entry} onLoad={handleLoad} /> |
| 176 | + ))} |
| 177 | + </div> |
| 178 | + )} |
| 179 | + </div> |
| 180 | + ); |
| 181 | +} |
| 182 | + |
| 183 | +// ── Single plugin row ────────────────────────────────────────────────────────── |
| 184 | + |
| 185 | +function WAMPluginRow({ |
| 186 | + entry, |
| 187 | + onLoad, |
| 188 | +}: { |
| 189 | + entry: WAMCatalogEntry; |
| 190 | + onLoad: (entry: WAMCatalogEntry) => void; |
| 191 | +}) { |
| 192 | + return ( |
| 193 | + <div |
| 194 | + className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-zinc-300 hover:bg-white/5" |
| 195 | + data-testid="wam-plugin-row" |
| 196 | + data-plugin-id={entry.id} |
| 197 | + title={`${entry.name} by ${entry.vendor} — ${entry.description}`} |
| 198 | + > |
| 199 | + {/* Category indicator */} |
| 200 | + <span |
| 201 | + className={`h-1.5 w-1.5 shrink-0 rounded-full ${ |
| 202 | + entry.category === 'instrument' ? 'bg-emerald-400' : 'bg-blue-400' |
| 203 | + }`} |
| 204 | + /> |
| 205 | + <span className="flex-1 truncate">{entry.name}</span> |
| 206 | + <span className="shrink-0 text-[10px] text-zinc-500 max-w-[70px] truncate"> |
| 207 | + {entry.subcategory} |
| 208 | + </span> |
| 209 | + <button |
| 210 | + onClick={(e) => { |
| 211 | + e.stopPropagation(); |
| 212 | + onLoad(entry); |
| 213 | + }} |
| 214 | + className="shrink-0 rounded bg-violet-600/60 px-1.5 py-0.5 text-[9px] font-medium text-white hover:bg-violet-500" |
| 215 | + data-testid="wam-load-btn" |
| 216 | + title={`Load ${entry.name}`} |
| 217 | + aria-label={`Load ${entry.name}`} |
| 218 | + > |
| 219 | + Load |
| 220 | + </button> |
| 221 | + </div> |
| 222 | + ); |
| 223 | +} |
0 commit comments