ee6bdd8241
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers (resendForkState — deafen+quality only) instead of clobbering live mic/video/ deafen back to the join-time snapshot. - C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL published track instead of getUserMedia on the browser DEFAULT mic (which could measure silence while the user spoke on another device → auto-mute an active speaker). Fails safe (never mutes) when call_state is null OR empty. - C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms debounce) with an early-return so unchanged state doesn't re-render. - C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent); C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards. Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier clean, build OK, 677 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
9.6 KiB
TypeScript
268 lines
9.6 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),
|
||
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>
|
||
);
|
||
}
|