feat(call): in-call soundboard, quality controls, room call-permissions
Element Call is now consumed as our self-built fork (@lotusguild/element-call-embedded); wire up its previously-dormant capabilities and document the fork as live. Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the call as a real published track (io.lotus.inject_audio) plus local playback. Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard account data (synced across devices). Gated by a Settings toggle + volume. Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any room cap. Room admins set caps and hard call-permissions (allow_screenshare / allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons. - New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality, useCallQuality, callQuality (+ unit tests). - Optimistic-write RoomQuality admin UI (no stale-state clobber). - Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2 manual-test steps. Numeric quality caps are client-cooperative; screenshare/camera permissions are hard-enforced server-side (see LotusGuild/matrix voice-limit-guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomQualityContent } from '../../utils/callQuality';
|
||||
|
||||
type CallControlsProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -88,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
|
||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||
// users don't click dead buttons. Absent/true = allowed.
|
||||
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = cameraAllowed || video;
|
||||
const showScreenshare = screenshareAllowed || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
@@ -339,24 +356,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} />
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
{/* Show a forbidden control while its track is still live so the
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Switch, Text } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import {
|
||||
AUDIO_BITRATE_OPTIONS,
|
||||
RoomQualityContent,
|
||||
SCREENSHARE_BITRATE_OPTIONS,
|
||||
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||
} from '../../../utils/callQuality';
|
||||
|
||||
// Only the numeric cap keys are edited via `update`; the boolean policy keys
|
||||
// are handled by `setAllow`.
|
||||
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
|
||||
|
||||
// String <-> numeric bridge for SettingsSelect (which needs string values).
|
||||
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
|
||||
|
||||
const CAP_KEYS: (keyof RoomQualityContent)[] = [
|
||||
'audio_max_kbps',
|
||||
'screenshare_max_kbps',
|
||||
'screenshare_max_fps',
|
||||
'allow_screenshare',
|
||||
'allow_camera',
|
||||
];
|
||||
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
|
||||
CAP_KEYS.every((k) => a[k] === b[k]);
|
||||
|
||||
type RoomQualityProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
};
|
||||
/**
|
||||
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
|
||||
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
|
||||
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
|
||||
*/
|
||||
export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
|
||||
|
||||
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (next: RoomQualityContent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
|
||||
// back via /sync (not when sendStateEvent resolves), so consecutive edits
|
||||
// must build on the pending write — otherwise a second edit spreads a stale
|
||||
// `caps` and silently drops the first. `effective` is what the UI shows and
|
||||
// what each edit merges into; it's reconciled below once the echo lands.
|
||||
const [pending, setPending] = useState<RoomQualityContent | null>(null);
|
||||
const effective = pending ?? caps;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
// Revert the optimistic view if the write failed…
|
||||
if (submitState.status === AsyncStatus.Error) {
|
||||
setPending(null);
|
||||
return;
|
||||
}
|
||||
// …or drop it once the synced state actually reflects it.
|
||||
if (capsEqual(caps, pending)) setPending(null);
|
||||
}, [caps, pending, submitState.status]);
|
||||
|
||||
const commit = (next: RoomQualityContent) => {
|
||||
setPending(next);
|
||||
submit(next);
|
||||
};
|
||||
|
||||
const update = (key: CapKey, value: string) => {
|
||||
const next: RoomQualityContent = { ...effective };
|
||||
if (value === 'auto') delete next[key];
|
||||
else next[key] = parseInt(value, 10);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
|
||||
const next: RoomQualityContent = { ...effective };
|
||||
// Absent = allowed, so only persist the key when forbidding.
|
||||
if (allowed) delete next[key];
|
||||
else next[key] = false;
|
||||
commit(next);
|
||||
};
|
||||
|
||||
// Absent/true = allowed.
|
||||
const screenshareAllowed = effective.allow_screenshare !== false;
|
||||
const cameraAllowed = effective.allow_camera !== false;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Call Permissions"
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Control what participants may share in this room. These are enforced on the server for
|
||||
every Matrix client (Element, FluffyChat, Lotus Chat, …).
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box direction="Column" gap="300">
|
||||
<SettingTile
|
||||
title="Allow Screen Sharing"
|
||||
description="When off, no one can share their screen in this room."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={screenshareAllowed}
|
||||
onChange={(v) => setAllow('allow_screenshare', v)}
|
||||
disabled={!canEdit || submitting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Allow Camera"
|
||||
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={cameraAllowed}
|
||||
onChange={(v) => setAllow('allow_camera', v)}
|
||||
disabled={!canEdit || submitting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SettingTile
|
||||
title="Call Quality Caps"
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
|
||||
this room. Lotus Chat clamps each participant to these ceilings (best-effort — applies
|
||||
to Lotus Chat clients). Auto = no cap.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box direction="Column" gap="300">
|
||||
<SettingTile
|
||||
title="Max Microphone Bitrate"
|
||||
after={
|
||||
<SettingsSelect
|
||||
value={toValue(effective.audio_max_kbps)}
|
||||
onChange={(v) => update('audio_max_kbps', v)}
|
||||
options={AUDIO_BITRATE_OPTIONS}
|
||||
disabled={!canEdit || submitting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Max Screenshare Bitrate"
|
||||
after={
|
||||
<SettingsSelect
|
||||
value={toValue(effective.screenshare_max_kbps)}
|
||||
onChange={(v) => update('screenshare_max_kbps', v)}
|
||||
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||
disabled={!canEdit || submitting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Max Screenshare Framerate"
|
||||
after={
|
||||
<SettingsSelect
|
||||
value={toValue(effective.screenshare_max_fps)}
|
||||
onChange={(v) => update('screenshare_max_fps', v)}
|
||||
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||
disabled={!canEdit || submitting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
|
||||
export * from './RoomJoinRules';
|
||||
export * from './RoomProfile';
|
||||
export * from './RoomPublish';
|
||||
export * from './RoomQuality';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RoomLocalAddresses,
|
||||
RoomPublishedAddresses,
|
||||
RoomPublish,
|
||||
RoomQuality,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
RoomVoiceLimit,
|
||||
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Voice</Text>
|
||||
<RoomVoiceLimit permissions={permissions} />
|
||||
<RoomQuality permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '../../../utils/lotusDenoiseUtils';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
ComposerToolbarSettings,
|
||||
DateFormat,
|
||||
@@ -53,9 +54,16 @@ import {
|
||||
MessageSpacing,
|
||||
NoiseSuppressionMode,
|
||||
RingtoneId,
|
||||
ScreenshareBitrate,
|
||||
ScreenshareFramerate,
|
||||
Settings,
|
||||
settingsAtom,
|
||||
} from '../../../state/settings';
|
||||
import {
|
||||
AUDIO_BITRATE_OPTIONS,
|
||||
SCREENSHARE_BITRATE_OPTIONS,
|
||||
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||
} from '../../../utils/callQuality';
|
||||
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
||||
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
@@ -1221,6 +1229,18 @@ function Calls() {
|
||||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
|
||||
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
|
||||
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
|
||||
settingsAtom,
|
||||
'screenshareBitrate',
|
||||
);
|
||||
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
|
||||
settingsAtom,
|
||||
'screenshareFramerate',
|
||||
);
|
||||
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||
|
||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||
setCallJoinLeaveSound(value);
|
||||
if (value !== 'off') playCallJoinSound(value);
|
||||
@@ -1616,6 +1636,80 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Microphone Bitrate"
|
||||
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||||
after={
|
||||
<SettingsSelect<CallAudioBitrate>
|
||||
value={callAudioBitrate}
|
||||
onChange={setCallAudioBitrate}
|
||||
options={AUDIO_BITRATE_OPTIONS}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screenshare Bitrate"
|
||||
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareBitrate>
|
||||
value={screenshareBitrate}
|
||||
onChange={setScreenshareBitrate}
|
||||
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screenshare Framerate"
|
||||
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareFramerate>
|
||||
value={screenshareFramerate}
|
||||
onChange={setScreenshareFramerate}
|
||||
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Soundboard"
|
||||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||||
after={
|
||||
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
|
||||
}
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<SettingTile
|
||||
title="Soundboard Volume"
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user