57da9a6ce8
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on upload via getAudioDurationMs, and captured on preview for existing clips); the preview button now toggles play/stop with a 'now playing' equalizer indicator; reworked the volume control into a fixed cell with a % readout so the slider's max no longer collides with the delete button. Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being truncated with an ellipsis; cards grow to fit. TODO: logged the basic audio-editor / video->audio-extractor as a large project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, 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>();
|
||
|
||
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||
// guard those setState calls against the component unmounting first.
|
||
const mountedRef = useRef(true);
|
||
useEffect(
|
||
() => () => {
|
||
mountedRef.current = false;
|
||
},
|
||
[],
|
||
);
|
||
|
||
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 = () => {
|
||
if (!mountedRef.current) return;
|
||
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),
|
||
minHeight: toRem(76),
|
||
height: 'auto',
|
||
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"
|
||
style={{
|
||
maxWidth: '100%',
|
||
textAlign: 'center',
|
||
wordBreak: 'break-word',
|
||
lineHeight: 1.15,
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 3,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{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>
|
||
);
|
||
}
|