import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getPttSettings } from '../lib/pttSettings'; import { codeToShortcut } from '../lib/globalShortcut'; import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback'; import { addSound, deleteSound, getSoundBlob, listSounds, MAX_SOUND_BYTES, reorderCategory, type SoundboardEntry, subscribeSoundboardChanges, updateSound, } from '../lib/soundboardStorage'; import { Modal } from './Modal'; import { AlertIcon, ChevronDownIcon, PencilIcon, PlusIcon, SpinnerIcon, TrashIcon, } from './icons'; interface Props { open: boolean; onClose: () => void; } const BYTES_PER_MB = 1024 * 1024; const CATEGORY_LIST_ID = 'sb-category-list'; // Admin UI for the soundboard. Users add, rename, categorise, reorder, // assign hotkeys, adjust per-sound volume, preview and delete clips here. // In-call panel only reads the resulting manifest. export function SoundboardManagerDialog({ open, onClose }: Props) { const { t } = useTranslation(['app']); const fileRef = useRef(null); const previewRef = useRef(null); const previewUrlRef = useRef(null); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); const [previewingId, setPreviewingId] = useState(null); const refresh = useCallback(async () => { setLoading(true); try { setEntries(await listSounds()); } catch (err: unknown) { console.error('listSounds failed', err); } finally { setLoading(false); } }, []); useEffect(() => { if (!open) return; void refresh(); // External mutations (hotkey fires, multi-tab edits) should reflect // immediately while the dialog is open. const unsub = subscribeSoundboardChanges(() => { void refresh(); }); return () => { unsub(); stopPreview(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, refresh]); useEffect(() => { return () => stopPreview(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const categories = useMemo(() => { const set = new Set(); for (const e of entries) if (e.category) set.add(e.category); return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [entries]); const grouped = useMemo(() => { const map = new Map(); for (const e of entries) { const key = e.category ?? ''; const arr = map.get(key); if (arr) arr.push(e); else map.set(key, [e]); } return map; }, [entries]); function stopPreview(): void { const el = previewRef.current; if (el) { try { el.pause(); } catch { /* ignore */ } el.src = ''; } previewRef.current = null; if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = null; } setPreviewingId(null); } async function handleAdd(file: File): Promise { setError(null); setBusyId('__add'); try { await addSound({ file }); await refresh(); } catch (err: unknown) { const code = err instanceof Error ? err.message : 'add_failed'; if (code === 'sound_too_large') { setError( t('app:soundboard.error_too_large', { defaultValue: 'Datei zu groß (max {{max}} MB).', max: MAX_SOUND_BYTES / BYTES_PER_MB, }), ); } else if (code === 'sound_not_audio') { setError( t('app:soundboard.error_not_audio', { defaultValue: 'Nur Audio-Dateien werden unterstützt.', }), ); } else { setError( t('app:soundboard.error_generic', { defaultValue: 'Sound konnte nicht gespeichert werden.', }), ); } } finally { setBusyId(null); if (fileRef.current) fileRef.current.value = ''; } } async function handlePatch( id: string, patch: Parameters[1], ): Promise { setBusyId(id); try { await updateSound(id, patch); } catch (err: unknown) { console.error('updateSound failed', err); } finally { setBusyId(null); } } async function handleDelete(id: string): Promise { if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) { return; } setBusyId(id); try { await deleteSound(id); invalidateSoundCache(id); if (previewingId === id) stopPreview(); } catch (err: unknown) { console.error('deleteSound failed', err); } finally { setBusyId(null); } } async function handlePreview(entry: SoundboardEntry): Promise { if (previewingId === entry.id) { stopPreview(); return; } stopPreview(); const blob = await getSoundBlob(entry.id); if (!blob) return; const url = URL.createObjectURL(blob); const el = new Audio(url); el.volume = entry.gain; el.onended = () => stopPreview(); el.onerror = () => stopPreview(); el.play().catch(() => stopPreview()); previewRef.current = el; previewUrlRef.current = url; setPreviewingId(entry.id); // Opportunistic warm-up of the AudioBuffer cache so the first in-call // playback doesn't pause on decode. void preload(entry.id); } async function handleReorder( category: string | null, idx: number, dir: -1 | 1, ): Promise { const bucket = grouped.get(category ?? '') ?? []; const next = idx + dir; if (next < 0 || next >= bucket.length) return; const reordered = bucket.slice(); const tmp = reordered[idx]!; reordered[idx] = reordered[next]!; reordered[next] = tmp; setBusyId('__reorder:' + (category ?? '')); try { await reorderCategory( category, reordered.map((e) => e.id), ); } catch (err: unknown) { console.error('reorderCategory failed', err); } finally { setBusyId(null); } } if (!open) return null; return (

{t('app:soundboard.manager_hint', { defaultValue: 'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.', })}

{ const f = e.target.files?.[0]; if (f) void handleAdd(f); }} />
{error && (

{error}

)} {categories.map((c) => ( {loading ? (
) : entries.length === 0 ? (

{t('app:soundboard.empty', { defaultValue: 'Noch keine Sounds. Lade oben welche hoch.', })}

) : (
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => { const category = categoryKey === '' ? null : categoryKey; return ( ); })}
)}
); } // --------------------------------------------------------------------------- interface GroupProps { category: string | null; entries: SoundboardEntry[]; entriesTotal: SoundboardEntry[]; busyId: string | null; previewingId: string | null; onPatch: (id: string, patch: Parameters[1]) => Promise; onDelete: (id: string) => Promise; onPreview: (entry: SoundboardEntry) => Promise; onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise; } function SoundboardCategoryGroup({ category, entries, entriesTotal, busyId, previewingId, onPatch, onDelete, onPreview, onReorder, }: GroupProps) { const { t } = useTranslation(['app']); const [open, setOpen] = useState(true); const label = category ?? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' }); return (
{open && (
    {entries.map((entry, idx) => ( onReorder(category, idx, -1)} onReorderDown={() => onReorder(category, idx, 1)} /> ))}
)}
); } // --------------------------------------------------------------------------- interface RowProps { entry: SoundboardEntry; entriesTotal: SoundboardEntry[]; isFirst: boolean; isLast: boolean; busy: boolean; previewing: boolean; onPatch: (id: string, patch: Parameters[1]) => Promise; onDelete: (id: string) => Promise; onPreview: (entry: SoundboardEntry) => Promise; onReorderUp: () => Promise; onReorderDown: () => Promise; } function SoundboardRow({ entry, entriesTotal, isFirst, isLast, busy, previewing, onPatch, onDelete, onPreview, onReorderUp, onReorderDown, }: RowProps) { const { t } = useTranslation(['app']); const [editingName, setEditingName] = useState(false); const [nameDraft, setNameDraft] = useState(entry.name); const [categoryDraft, setCategoryDraft] = useState(entry.category ?? ''); const [capturingHotkey, setCapturingHotkey] = useState(false); const [hotkeyError, setHotkeyError] = useState(null); useEffect(() => { setNameDraft(entry.name); setCategoryDraft(entry.category ?? ''); }, [entry.name, entry.category]); useEffect(() => { if (!capturingHotkey) return; const onKey = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (e.code === 'Escape') { setCapturingHotkey(false); setHotkeyError(null); return; } // Conflict checks: PTT + other soundboard entries with this code. const ptt = getPttSettings(); if (ptt.enabled && ptt.key === e.code) { setHotkeyError( t('app:soundboard.hotkey_conflict_ptt', { defaultValue: 'Konflikt mit Push-to-Talk.', }), ); return; } const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code); if (taken) { setHotkeyError( t('app:soundboard.hotkey_conflict_sound', { defaultValue: 'Bereits von "{{name}}" belegt.', name: taken.name, }), ); return; } setHotkeyError(null); setCapturingHotkey(false); void onPatch(entry.id, { hotkey: e.code }); }; window.addEventListener('keydown', onKey, { capture: true }); return () => window.removeEventListener('keydown', onKey, { capture: true }); }, [capturingHotkey, entriesTotal, entry.id, onPatch, t]); const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null; return (
  • {editingName ? ( setNameDraft(e.target.value)} onBlur={() => { setEditingName(false); if (nameDraft.trim() && nameDraft !== entry.name) { void onPatch(entry.id, { name: nameDraft }); } else { setNameDraft(entry.name); } }} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } if (e.key === 'Escape') { setNameDraft(entry.name); setEditingName(false); } }} className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none" /> ) : ( )}

    {(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}

    setCategoryDraft(e.target.value)} onBlur={() => { const next = categoryDraft.trim() || null; if (next !== (entry.category ?? null)) { void onPatch(entry.id, { category: next }); } }} list={CATEGORY_LIST_ID} placeholder={t('app:soundboard.category_placeholder', { defaultValue: 'Kategorie…', })} className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none" />
    {entry.hotkey && !capturingHotkey && ( )}
    void onPatch(entry.id, { gain: Number(e.target.value) })} className="accent-accent w-20" title={t('app:soundboard.gain_title', { defaultValue: 'Lautstärke', })} />
    {hotkeyError && capturingHotkey && (

    {hotkeyError}

    )}
  • ); }