a9505ca5b2
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for in-call audio clips. - Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event io.lotus.soundboard, inherited by child rooms via parent-space aggregation), global refs (io.lotus.soundboard_rooms), and the personal pack (io.lotus.soundboard account data; the v1 flat-list content is migrated to the pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) + hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room, deduped). Unit-tested (migration + slug). - Management: reusable SoundboardPackEditor (name + emoji + per-clip volume + delete + upload + batched save), power-level-gated for room packs like emoji packs; a Soundboard page wired into Room + Space settings. - In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack (emoji + name tiles), sourcing room+parent-space U personal clips; a Manage toggle embeds the editors; per-clip volume x master volume on playback. - Spam guard: host gates on a playing key (fork enforces one clip at a time). - Control bar: Mute-Screenshare moved next to the Screenshare button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
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<RectCords>();
|
||
const [manage, setManage] = useState(false);
|
||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||
const [error, setError] = useState<string>();
|
||
|
||
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<HTMLButtonElement> = (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 (
|
||
<PopOut
|
||
anchor={cords}
|
||
position="Top"
|
||
align="Center"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||
<Box
|
||
shrink="No"
|
||
alignItems="Center"
|
||
justifyContent="SpaceBetween"
|
||
gap="200"
|
||
style={{
|
||
padding: config.space.S200,
|
||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Soundboard</Text>
|
||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||
<Text size="T200" priority="300">
|
||
Manage
|
||
</Text>
|
||
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||
</Box>
|
||
</Box>
|
||
|
||
<Scroll size="300" hideTrack visibility="Hover">
|
||
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||
{manage ? (
|
||
<>
|
||
<RoomSoundboardPack room={room} stateKey="" />
|
||
<UserSoundboardPack />
|
||
</>
|
||
) : (
|
||
<>
|
||
{groups.length === 0 && (
|
||
<Text size="T200" priority="300">
|
||
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||
a pack in Space settings.
|
||
</Text>
|
||
)}
|
||
{groups.map((g) => (
|
||
<Box key={g.id} direction="Column" gap="100">
|
||
<Text size="L400">{g.name}</Text>
|
||
<Box wrap="Wrap" gap="200">
|
||
{g.clips.map((clip) => {
|
||
const key = `${g.id}|${clip.shortcode}`;
|
||
const flat: FlatClip = {
|
||
key,
|
||
packId: g.id,
|
||
packName: g.name,
|
||
clip,
|
||
};
|
||
return (
|
||
<Box
|
||
key={key}
|
||
as="button"
|
||
direction="Column"
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
gap="100"
|
||
disabled={!!playingKey}
|
||
onClick={() => 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,
|
||
}}
|
||
>
|
||
<Text size="H4">
|
||
{playingKey === key ? (
|
||
<Spinner size="200" />
|
||
) : (
|
||
clip.emoji || '🔊'
|
||
)}
|
||
</Text>
|
||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||
{clip.name}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</>
|
||
)}
|
||
{error && (
|
||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||
{error}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
</Scroll>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
>
|
||
<TooltipProvider
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text>Soundboard</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(triggerRef) => (
|
||
<IconButton
|
||
ref={triggerRef}
|
||
variant="Surface"
|
||
fill="Soft"
|
||
radii="400"
|
||
size="400"
|
||
onClick={handleOpen}
|
||
outlined
|
||
aria-label="Soundboard"
|
||
aria-expanded={!!cords}
|
||
aria-haspopup="menu"
|
||
>
|
||
<Icon size="400" src={Icons.BellRing} />
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
</PopOut>
|
||
);
|
||
}
|