import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { Box, Icon, IconButton, Icons, Menu, PopOut, RectCords, Scroll, Spinner, Switch, Text, Tooltip, TooltipProvider, color, config, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useAtomValue } from 'jotai'; import { CallEmbed } from '../../plugins/call'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks'; import { SoundboardClipReader } from '../../plugins/soundboard'; import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view'; import { stopPropagation } from '../../utils/keyboard'; import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips'; type CallSoundboardProps = { callEmbed: CallEmbed; }; type FlatClip = { key: string; // packId|shortcode packId: string; packName: string; clip: SoundboardClipReader; }; /** * [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs * relevant to the call room (the room + parent spaces ∪ the user's personal * pack), just like custom emoji. Playing a clip publishes it into the call via * the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally. * A management toggle reveals the pack editors (personal + this room, if * permitted). Space-wide packs are managed from Space settings. */ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { const mx = useMatrixClient(); const { room } = callEmbed; const roomToParents = useAtomValue(roomToParentsAtom); const packRooms = useImagePackRooms(room.roomId, roomToParents); const packs = useRelevantSoundboardPacks(packRooms); const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume'); const master = Math.max(0, Math.min(1, soundboardVolume / 100)); const [cords, setCords] = useState(); const [manage, setManage] = useState(false); const [playingKey, setPlayingKey] = useState(); // host-side spam guard const [error, setError] = useState(); const groups = useMemo( () => packs .map((pack) => ({ id: pack.id, name: pack.meta.name ?? 'Soundboard', clips: pack.getClips(), })) .filter((g) => g.clips.length > 0), [packs], ); const handleOpen: MouseEventHandler = (evt) => { setError(undefined); setCords(evt.currentTarget.getBoundingClientRect()); }; const play = useCallback( async (flat: FlatClip) => { if (playingKey) return; // one at a time (fork also enforces this) setPlayingKey(flat.key); setError(undefined); const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k)); try { const url = await resolveClipObjectUrl(mx, flat.clip.url); const vol = (flat.clip.volume / 100) * master; callEmbed.control.injectAudio(url, vol); const audio = playClipLocally(url, vol); if (audio) { audio.addEventListener('ended', done, { once: true }); audio.addEventListener('error', done, { once: true }); } else { done(); } // Safety: clear the guard even if the audio never signals end. window.setTimeout(done, 30_000); } catch { setError('Could not play that clip.'); done(); } }, [mx, callEmbed, master, playingKey], ); return ( setCords(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > Soundboard Manage {manage ? ( <> ) : ( <> {groups.length === 0 && ( No soundboard clips here yet. Turn on Manage to upload some, or add a pack in Space settings. )} {groups.map((g) => ( {g.name} {g.clips.map((clip) => { const key = `${g.id}|${clip.shortcode}`; const flat: FlatClip = { key, packId: g.id, packName: g.name, clip, }; return ( play(flat)} aria-label={`Play ${clip.name}`} style={{ width: toRem(76), height: toRem(76), padding: config.space.S100, borderRadius: config.radii.R400, border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, background: playingKey === key ? color.Primary.Container : color.SurfaceVariant.Container, cursor: playingKey ? 'default' : 'pointer', opacity: playingKey && playingKey !== key ? 0.5 : 1, }} > {playingKey === key ? ( ) : ( clip.emoji || '🔊' )} {clip.name} ); })} ))} )} {error && ( {error} )} } > Soundboard } > {(triggerRef) => ( )} ); }