import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useCall } from '../context/CallContext'; import { codeToShortcut } from '../lib/globalShortcut'; import { DEFAULT_PREFS, getPrefs, listSounds, type SoundboardEntry, type SoundboardPrefs, subscribeSoundboardChanges, } from '../lib/soundboardStorage'; import { ChevronDownIcon, XIcon } from './icons'; interface Props { onClose: () => void; } // In-call popover that lists every stored sound grouped by category. Click a // pad to play through the active pipeline. Hotkey-badge shows the bound // accelerator (if any). Master + monitor sliders adjust the pipeline gains. export function SoundboardPanel({ onClose }: Props) { const { t } = useTranslation(['app']); const { playSoundboard, stopSoundboard, activeSoundboardIds, setSoundboardMasterGain, setSoundboardMonitorGain, } = useCall(); const [entries, setEntries] = useState([]); const [prefs, setPrefs] = useState(() => ({ ...DEFAULT_PREFS })); const [collapsed, setCollapsed] = useState>({}); const [query, setQuery] = useState(''); const closeRef = useRef(onClose); closeRef.current = onClose; const refresh = useCallback(async () => { try { const [all, p] = await Promise.all([listSounds(), getPrefs()]); setEntries(all); setPrefs(p); } catch (err: unknown) { console.warn('soundboard panel refresh failed', err); } }, []); useEffect(() => { void refresh(); const unsub = subscribeSoundboardChanges(() => { void refresh(); }); return unsub; }, [refresh]); // Close on Esc — tapping outside is handled by the trigger's parent. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeRef.current(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return entries; return entries.filter( (e) => e.name.toLowerCase().includes(q) || (e.category ?? '').toLowerCase().includes(q), ); }, [entries, query]); const grouped = useMemo(() => { const map = new Map(); for (const e of filtered) { const key = e.category ?? ''; const arr = map.get(key); if (arr) arr.push(e); else map.set(key, [e]); } return map; }, [filtered]); function toggleCategory(key: string): void { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); } return (

{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}

{entries.length > 0 && (
setQuery(e.target.value)} placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })} className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" />
)}
{entries.length === 0 ? (

{t('app:soundboard.panel_empty', { defaultValue: 'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.', })}

) : filtered.length === 0 ? (

{t('app:soundboard.panel_no_matches', { defaultValue: 'Keine Treffer.', })}

) : (
{Array.from(grouped.entries()).map(([key, bucket]) => ( toggleCategory(key)} onActivate={(id) => { if (activeSoundboardIds.has(id)) { stopSoundboard(id); } else { void playSoundboard(id); } }} /> ))}
)}
{ setPrefs((p) => ({ ...p, masterGain: v })); void setSoundboardMasterGain(v); }} /> { setPrefs((p) => ({ ...p, monitorGain: v })); void setSoundboardMonitorGain(v); }} />
); } interface CategorySectionProps { categoryKey: string; entries: SoundboardEntry[]; collapsed: boolean; activeIds: ReadonlySet; onToggle: () => void; onActivate: (id: string) => void; } function CategorySection({ categoryKey, entries, collapsed, activeIds, onToggle, onActivate, }: CategorySectionProps) { const { t } = useTranslation(['app']); const label = categoryKey === '' ? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' }) : categoryKey; return (
{!collapsed && (
{entries.map((entry) => ( onActivate(entry.id)} /> ))}
)}
); } interface PadProps { entry: SoundboardEntry; active: boolean; onActivate: () => void; } function SoundPad({ entry, active, onActivate }: PadProps) { const { t } = useTranslation(['app']); const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null; const base = 'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40'; const toneClass = active ? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110' : 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3'; return ( ); } function VolumeSlider({ label, value, onChange, }: { label: string; value: number; onChange: (v: number) => void; }) { return ( ); }