2026-07-01 23:21:50 -04:00
|
|
|
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
2026-06-30 22:34:17 -04:00
|
|
|
|
import {
|
|
|
|
|
|
Box,
|
|
|
|
|
|
Icon,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
|
|
|
|
|
Menu,
|
|
|
|
|
|
PopOut,
|
|
|
|
|
|
RectCords,
|
2026-07-01 23:21:50 -04:00
|
|
|
|
Scroll,
|
2026-06-30 22:34:17 -04:00
|
|
|
|
Spinner,
|
2026-07-01 23:21:50 -04:00
|
|
|
|
Switch,
|
2026-06-30 22:34:17 -04:00
|
|
|
|
Text,
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
|
color,
|
|
|
|
|
|
config,
|
2026-07-01 23:21:50 -04:00
|
|
|
|
toRem,
|
2026-06-30 22:34:17 -04:00
|
|
|
|
} from 'folds';
|
|
|
|
|
|
import FocusTrap from 'focus-trap-react';
|
2026-07-01 23:21:50 -04:00
|
|
|
|
import { useAtomValue } from 'jotai';
|
2026-06-30 22:34:17 -04:00
|
|
|
|
import { CallEmbed } from '../../plugins/call';
|
|
|
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
|
|
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
|
|
|
|
import { settingsAtom } from '../../state/settings';
|
2026-07-01 23:21:50 -04:00
|
|
|
|
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';
|
2026-06-30 22:34:17 -04:00
|
|
|
|
import { stopPropagation } from '../../utils/keyboard';
|
2026-07-01 23:21:50 -04:00
|
|
|
|
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
2026-06-30 22:34:17 -04:00
|
|
|
|
|
|
|
|
|
|
type CallSoundboardProps = {
|
|
|
|
|
|
callEmbed: CallEmbed;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-01 23:21:50 -04:00
|
|
|
|
type FlatClip = {
|
|
|
|
|
|
key: string; // packId|shortcode
|
|
|
|
|
|
packId: string;
|
|
|
|
|
|
packName: string;
|
|
|
|
|
|
clip: SoundboardClipReader;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 22:34:17 -04:00
|
|
|
|
/**
|
2026-07-01 23:21:50 -04:00
|
|
|
|
* [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.
|
2026-06-30 22:34:17 -04:00
|
|
|
|
*/
|
|
|
|
|
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|
|
|
|
|
const mx = useMatrixClient();
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const { room } = callEmbed;
|
|
|
|
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
|
|
|
|
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
|
|
|
|
|
const packs = useRelevantSoundboardPacks(packRooms);
|
2026-06-30 22:34:17 -04:00
|
|
|
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
2026-06-30 22:34:17 -04:00
|
|
|
|
|
|
|
|
|
|
const [cords, setCords] = useState<RectCords>();
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const [manage, setManage] = useState(false);
|
|
|
|
|
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
2026-06-30 22:34:17 -04:00
|
|
|
|
const [error, setError] = useState<string>();
|
|
|
|
|
|
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const groups = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
packs
|
|
|
|
|
|
.map((pack) => ({
|
|
|
|
|
|
id: pack.id,
|
|
|
|
|
|
name: pack.meta.name ?? 'Soundboard',
|
|
|
|
|
|
clips: pack.getClips(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((g) => g.clips.length > 0),
|
|
|
|
|
|
[packs],
|
|
|
|
|
|
);
|
2026-06-30 22:34:17 -04:00
|
|
|
|
|
|
|
|
|
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
|
|
|
|
setError(undefined);
|
|
|
|
|
|
setCords(evt.currentTarget.getBoundingClientRect());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const play = useCallback(
|
|
|
|
|
|
async (flat: FlatClip) => {
|
|
|
|
|
|
if (playingKey) return; // one at a time (fork also enforces this)
|
|
|
|
|
|
setPlayingKey(flat.key);
|
2026-06-30 22:34:17 -04:00
|
|
|
|
setError(undefined);
|
2026-07-01 23:21:50 -04:00
|
|
|
|
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
2026-06-30 22:34:17 -04:00
|
|
|
|
try {
|
2026-07-01 23:21:50 -04:00
|
|
|
|
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);
|
2026-06-30 22:34:17 -04:00
|
|
|
|
} catch {
|
|
|
|
|
|
setError('Could not play that clip.');
|
2026-07-01 23:21:50 -04:00
|
|
|
|
done();
|
2026-06-30 22:34:17 -04:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-07-01 23:21:50 -04:00
|
|
|
|
[mx, callEmbed, master, playingKey],
|
2026-06-30 22:34:17 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-01 23:21:50 -04:00
|
|
|
|
<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' }}>
|
2026-06-30 22:34:17 -04:00
|
|
|
|
<Text size="T200" priority="300">
|
2026-07-01 23:21:50 -04:00
|
|
|
|
Manage
|
2026-06-30 22:34:17 -04:00
|
|
|
|
</Text>
|
2026-07-01 23:21:50 -04:00
|
|
|
|
<Switch variant="Primary" value={manage} onChange={setManage} />
|
|
|
|
|
|
</Box>
|
2026-06-30 22:34:17 -04:00
|
|
|
|
</Box>
|
2026-07-01 23:21:50 -04:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-06-30 22:34:17 -04:00
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-07-01 23:21:50 -04:00
|
|
|
|
{(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>
|
2026-06-30 22:34:17 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|