221 lines
6.9 KiB
TypeScript
221 lines
6.9 KiB
TypeScript
|
|
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||
|
|
import {
|
||
|
|
Box,
|
||
|
|
Chip,
|
||
|
|
Icon,
|
||
|
|
IconButton,
|
||
|
|
Icons,
|
||
|
|
Menu,
|
||
|
|
PopOut,
|
||
|
|
RectCords,
|
||
|
|
Spinner,
|
||
|
|
Text,
|
||
|
|
Tooltip,
|
||
|
|
TooltipProvider,
|
||
|
|
color,
|
||
|
|
config,
|
||
|
|
} from 'folds';
|
||
|
|
import FocusTrap from 'focus-trap-react';
|
||
|
|
import { CallEmbed } from '../../plugins/call';
|
||
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||
|
|
import { useSoundboard } from '../../hooks/useSoundboard';
|
||
|
|
import { useSetting } from '../../state/hooks/settings';
|
||
|
|
import { settingsAtom } from '../../state/settings';
|
||
|
|
import { stopPropagation } from '../../utils/keyboard';
|
||
|
|
import {
|
||
|
|
SOUNDBOARD_ACCEPT,
|
||
|
|
SOUNDBOARD_MAX_CLIPS,
|
||
|
|
playClipLocally,
|
||
|
|
resolveClipObjectUrl,
|
||
|
|
} from '../../utils/soundboardClips';
|
||
|
|
|
||
|
|
type CallSoundboardProps = {
|
||
|
|
callEmbed: CallEmbed;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
|
||
|
|
* clip is published to peers as a separate track by the EC fork
|
||
|
|
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
|
||
|
|
* Clips are uploadable/managed here and synced across devices via the
|
||
|
|
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
|
||
|
|
*/
|
||
|
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||
|
|
const mx = useMatrixClient();
|
||
|
|
const { clips, addClip, removeClip } = useSoundboard();
|
||
|
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||
|
|
|
||
|
|
const [cords, setCords] = useState<RectCords>();
|
||
|
|
const [busyId, setBusyId] = useState<string>();
|
||
|
|
const [uploading, setUploading] = useState(false);
|
||
|
|
const [error, setError] = useState<string>();
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||
|
|
|
||
|
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
|
|
setError(undefined);
|
||
|
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePlay = useCallback(
|
||
|
|
async (id: string, mxc: string) => {
|
||
|
|
setBusyId(id);
|
||
|
|
setError(undefined);
|
||
|
|
try {
|
||
|
|
const objectUrl = await resolveClipObjectUrl(mx, mxc);
|
||
|
|
callEmbed.control.injectAudio(objectUrl, volume);
|
||
|
|
playClipLocally(objectUrl, volume);
|
||
|
|
} catch {
|
||
|
|
setError('Could not play that clip.');
|
||
|
|
} finally {
|
||
|
|
setBusyId(undefined);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[mx, callEmbed, volume],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleFile = useCallback(
|
||
|
|
async (file: File | undefined) => {
|
||
|
|
if (!file) return;
|
||
|
|
setUploading(true);
|
||
|
|
setError(undefined);
|
||
|
|
try {
|
||
|
|
await addClip(file);
|
||
|
|
} catch (e) {
|
||
|
|
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||
|
|
} finally {
|
||
|
|
setUploading(false);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[addClip],
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept={SOUNDBOARD_ACCEPT}
|
||
|
|
hidden
|
||
|
|
onChange={(e) => {
|
||
|
|
handleFile(e.target.files?.[0]);
|
||
|
|
e.target.value = '';
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<PopOut
|
||
|
|
anchor={cords}
|
||
|
|
position="Top"
|
||
|
|
align="Center"
|
||
|
|
content={
|
||
|
|
<FocusTrap
|
||
|
|
focusTrapOptions={{
|
||
|
|
initialFocus: false,
|
||
|
|
onDeactivate: () => setCords(undefined),
|
||
|
|
clickOutsideDeactivates: true,
|
||
|
|
escapeDeactivates: stopPropagation,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Menu style={{ maxWidth: '320px' }}>
|
||
|
|
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||
|
|
<Text size="L400">Soundboard</Text>
|
||
|
|
<Chip
|
||
|
|
variant="Secondary"
|
||
|
|
radii="Pill"
|
||
|
|
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
before={
|
||
|
|
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<Text size="B300">Upload</Text>
|
||
|
|
</Chip>
|
||
|
|
</Box>
|
||
|
|
|
||
|
|
{clips.length === 0 ? (
|
||
|
|
<Text size="T200" priority="300">
|
||
|
|
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
||
|
|
Clips sync across your devices.
|
||
|
|
</Text>
|
||
|
|
) : (
|
||
|
|
<Box wrap="Wrap" gap="200">
|
||
|
|
{clips.map((clip) => (
|
||
|
|
<Box
|
||
|
|
key={clip.id}
|
||
|
|
direction="Column"
|
||
|
|
gap="100"
|
||
|
|
style={{ position: 'relative' }}
|
||
|
|
>
|
||
|
|
<Chip
|
||
|
|
variant="SurfaceVariant"
|
||
|
|
radii="300"
|
||
|
|
disabled={busyId === clip.id}
|
||
|
|
onClick={() => handlePlay(clip.id, clip.url)}
|
||
|
|
before={
|
||
|
|
busyId === clip.id ? (
|
||
|
|
<Spinner size="100" />
|
||
|
|
) : (
|
||
|
|
<Icon size="100" src={Icons.Play} />
|
||
|
|
)
|
||
|
|
}
|
||
|
|
after={
|
||
|
|
<Icon
|
||
|
|
size="50"
|
||
|
|
src={Icons.Cross}
|
||
|
|
style={{ cursor: 'pointer' }}
|
||
|
|
onClick={(e: React.MouseEvent) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
removeClip(clip.id);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
|
||
|
|
{clip.name}
|
||
|
|
</Text>
|
||
|
|
</Chip>
|
||
|
|
</Box>
|
||
|
|
))}
|
||
|
|
</Box>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||
|
|
{error}
|
||
|
|
</Text>
|
||
|
|
)}
|
||
|
|
</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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|