feat(call): in-call soundboard, quality controls, room call-permissions
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 8s

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:
2026-06-30 22:34:17 -04:00
parent 02b2ce8109
commit 7c06b27c73
22 changed files with 1259 additions and 120 deletions
+2
View File
@@ -45,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -584,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
embed,
useCallback(() => {
+37 -13
View File
@@ -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"
+220
View File
@@ -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>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { settingsAtom } from '../state/settings';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { buildQualityPayload, RoomQualityContent } from '../utils/callQuality';
/**
* [P5-31] Apply the user's call quality settings (clamped by any room-level
* cap) to the Element Call fork via the `io.lotus.set_quality` widget action.
*
* The fork stores the settings and re-applies them on every (re)publish and
* reconnect, so we only need to (re)send when the payload changes or the widget
* becomes ready — no need to poll the track lifecycle here.
*/
export function useCallQuality(embed: CallEmbed): void {
const { callAudioBitrate, screenshareBitrate, screenshareFramerate } = useAtomValue(settingsAtom);
const roomQualityEvent = useStateEvent(embed.room, StateEvent.LotusRoomQuality);
const roomCaps = roomQualityEvent?.getContent<RoomQualityContent>();
// Depend on the primitive cap values (not the event object) so re-renders
// don't resend needlessly.
const audioCap = roomCaps?.audio_max_kbps;
const ssCap = roomCaps?.screenshare_max_kbps;
const fpsCap = roomCaps?.screenshare_max_fps;
useEffect(() => {
const payload = buildQualityPayload(
{ callAudioBitrate, screenshareBitrate, screenshareFramerate },
{ audio_max_kbps: audioCap, screenshare_max_kbps: ssCap, screenshare_max_fps: fpsCap },
);
const send = (): void => embed.control.setQuality(payload);
// Send now (settings are sticky fork-side even if tracks aren't up yet) and
// again once the widget signals ready, in case the transport wasn't up.
send();
const off = embed.onReady(send);
return off;
}, [embed, callAudioBitrate, screenshareBitrate, screenshareFramerate, audioCap, ssCap, fpsCap]);
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
SoundboardClip,
SoundboardContent,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
SOUNDBOARD_NAME_MAX,
readSoundboardClips,
} from '../utils/soundboardClips';
const KEY = AccountDataEvent.LotusSoundboard;
/**
* [P5-15] Read/write the user's personal soundboard, stored in the
* `io.lotus.soundboard` account data event (synced across devices like custom
* emoji/sticker packs). Uploading writes the audio to the media repo and
* appends an mxc reference.
*/
export function useSoundboard(): {
clips: SoundboardClip[];
addClip: (file: File, name?: string) => Promise<void>;
removeClip: (id: string) => Promise<void>;
renameClip: (id: string, name: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === KEY) {
const content = evt.getContent<SoundboardContent>();
setClips(Array.isArray(content?.clips) ? content.clips : []);
}
}, []),
);
useEffect(() => {
setClips(readSoundboardClips(mx));
}, [mx]);
const persist = useCallback(
async (next: SoundboardClip[]) => {
const content: SoundboardContent = { clips: next };
await (
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
).setAccountData(KEY, content);
},
[mx],
);
const addClip = useCallback(
async (file: File, name?: string) => {
const current = readSoundboardClips(mx);
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error('Clip is too large (max 1 MB).');
}
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
.trim()
.slice(0, SOUNDBOARD_NAME_MAX);
const clip: SoundboardClip = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: label || 'Clip',
url: mxc,
mimetype: file.type || undefined,
size: file.size,
};
await persist([...current, clip]);
},
[mx, persist],
);
const removeClip = useCallback(
async (id: string) => {
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
await persist(next);
},
[mx, persist],
);
const renameClip = useCallback(
async (id: string, name: string) => {
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
if (!trimmed) return;
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
await persist(next);
},
[mx, persist],
);
return { clips, addClip, removeClip, renameClip };
}
+38
View File
@@ -7,6 +7,17 @@ export enum CallControlEvent {
StateUpdate = 'state_update',
}
/**
* [lotus #7 / P5-31] Payload for the fork's `io.lotus.set_quality` action.
* All fields optional; `null` clears that cap. Bits/sec for bitrates, fps for
* framerate.
*/
export type LotusQualityPayload = {
audioMaxBitrate?: number | null;
screenshareMaxBitrate?: number | null;
screenshareMaxFramerate?: number | null;
};
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -358,6 +369,33 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
}
/**
* [lotus #3 / P5-15] Inject a soundboard clip into the call so other
* participants hear it. The fork publishes it as a separate LiveKit audio
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
* an https/blob URL the widget can fetch WITHOUT credentials — the host
* resolves an mxc clip to a `blob:` object URL first (authenticated media
* can't be fetched cross-realm by the widget). `volume` is 01.
*
* The local user does not hear their own published track, so callers should
* also play the clip locally for feedback.
*/
public injectAudio(url: string, volume = 1): void {
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
}
/**
* [lotus #7 / P5-31] Apply audio/screenshare encoding limits to the local
* published tracks (the fork's `io.lotus.set_quality` action, via
* `RTCRtpSender.setParameters` — no republish). Bitrates are bits/sec,
* framerate is fps. A field set to `null` clears that cap. Settings are
* sticky fork-side (re-applied on every re-publish / reconnect). Values are
* clamped fork-side, so out-of-range input can't brick the encoder.
*/
public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
}
public dispose() {
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();
+5
View File
@@ -179,6 +179,11 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively
lotusCallState: 'true',
lotusTransparent: 'true',
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
// soundboard can publish clips into the call. Dormant until the host
// sends io.lotus.inject_audio (only on an explicit user click), so
// arming it for every call is safe.
lotusAudioInject: 'true',
});
if (denoiseMode === 'ml') {
+21
View File
@@ -24,6 +24,13 @@ export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
// 'none' is silent (visual-only incoming-call UI).
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
// utils/callQuality.ts and applied via the io.lotus.set_quality widget action.
export type CallAudioBitrate = 'auto' | '32' | '64' | '96' | '128' | '256';
export type ScreenshareBitrate = 'auto' | '500' | '1500' | '3000' | '8000';
export type ScreenshareFramerate = 'auto' | '15' | '30' | '60';
export type ChatBackground =
| 'none'
| 'blueprint'
@@ -156,6 +163,14 @@ export interface Settings {
ringtoneId: RingtoneId;
ringtoneVolume: number; // 0100
// [P5-31] Call quality controls
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
soundboardVolume: number; // 0100
seasonalThemeOverride:
| 'auto'
| 'off'
@@ -253,6 +268,12 @@ const defaultSettings: Settings = {
ringtoneId: 'classic',
ringtoneVolume: 70,
callAudioBitrate: 'auto',
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
};
+60
View File
@@ -0,0 +1,60 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildQualityPayload } from './callQuality';
describe('buildQualityPayload', () => {
it("sends null for every 'auto' field so a prior cap is reset", () => {
const payload = buildQualityPayload({
callAudioBitrate: 'auto',
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
});
assert.deepEqual(payload, {
audioMaxBitrate: null,
screenshareMaxBitrate: null,
screenshareMaxFramerate: null,
});
});
it('converts kbps user settings to bits/sec and passes fps through', () => {
const payload = buildQualityPayload({
callAudioBitrate: '64',
screenshareBitrate: '1500',
screenshareFramerate: '30',
});
assert.equal(payload.audioMaxBitrate, 64_000);
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
assert.equal(payload.screenshareMaxFramerate, 30);
});
it('clamps the user setting down to the room cap (lower wins)', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: '256', screenshareBitrate: '8000', screenshareFramerate: '60' },
{ audio_max_kbps: 64, screenshare_max_kbps: 1500, screenshare_max_fps: 30 },
);
assert.equal(payload.audioMaxBitrate, 64_000);
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
assert.equal(payload.screenshareMaxFramerate, 30);
});
it('does not raise a user setting that is already below the room cap', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: '32', screenshareBitrate: 'auto', screenshareFramerate: '15' },
{ audio_max_kbps: 128, screenshare_max_kbps: 3000, screenshare_max_fps: 60 },
);
assert.equal(payload.audioMaxBitrate, 32_000);
// user 'auto' but room caps screenshare bitrate -> room cap applies
assert.equal(payload.screenshareMaxBitrate, 3_000_000);
assert.equal(payload.screenshareMaxFramerate, 15);
});
it('applies a room cap even when the user left the field on auto', () => {
const payload = buildQualityPayload(
{ callAudioBitrate: 'auto', screenshareBitrate: 'auto', screenshareFramerate: 'auto' },
{ audio_max_kbps: 96 },
);
assert.equal(payload.audioMaxBitrate, 96_000);
assert.equal(payload.screenshareMaxBitrate, null);
assert.equal(payload.screenshareMaxFramerate, null);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { LotusQualityPayload } from '../plugins/call/CallControl';
import { CallAudioBitrate, ScreenshareBitrate, ScreenshareFramerate } from '../state/settings';
/**
* [P5-31] Room-level quality caps, stored in the `io.lotus.room_quality` state
* event. Admins set a ceiling every client must stay under. Values mirror the
* user-setting units (kbps / fps); `undefined`/absent = no cap.
*
* NOTE: the client applies these as a best-effort UX cap. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (a `voice-limit-guard`-style
* sidecar on LXC 151 that reads this event) — see LOTUS_TODO.md P5-31.
*/
export type RoomQualityContent = {
// Numeric caps: client-cooperative only (our fork honors them; the SFU cannot
// enforce publisher bitrate/fps — LiveKit forwards, never transcodes).
audio_max_kbps?: number;
screenshare_max_kbps?: number;
screenshare_max_fps?: number;
// Publish-source policy: HARD-enforced server-side for ALL clients by the
// voice-limit-guard (it re-signs the LiveKit JWT's canPublishSources).
// Absent/true = allowed; only an explicit false forbids.
allow_screenshare?: boolean;
allow_camera?: boolean;
};
// Selectable options (kbps / fps), shared by the settings UI and the room-admin
// UI so they stay in sync. Values are strings so they satisfy SettingsSelect's
// `T extends string` constraint; parsed to numbers in buildQualityPayload.
export const AUDIO_BITRATE_OPTIONS: { value: CallAudioBitrate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '32', label: '32 kbps' },
{ value: '64', label: '64 kbps' },
{ value: '96', label: '96 kbps' },
{ value: '128', label: '128 kbps' },
{ value: '256', label: '256 kbps' },
];
export const SCREENSHARE_BITRATE_OPTIONS: { value: ScreenshareBitrate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '500', label: '0.5 Mbps' },
{ value: '1500', label: '1.5 Mbps' },
{ value: '3000', label: '3 Mbps' },
{ value: '8000', label: '8 Mbps' },
];
export const SCREENSHARE_FRAMERATE_OPTIONS: { value: ScreenshareFramerate; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '15', label: '15 fps' },
{ value: '30', label: '30 fps' },
{ value: '60', label: '60 fps' },
];
/** Lower of two caps, treating `undefined` as "no cap on that side". */
const minCap = (a: number | undefined, b: number | undefined): number | undefined => {
if (a === undefined) return b;
if (b === undefined) return a;
return Math.min(a, b);
};
/** Parse a setting value ('auto' | numeric string) to a number or undefined. */
const num = (v: string): number | undefined => {
if (v === 'auto') return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
};
type QualitySettings = {
callAudioBitrate: CallAudioBitrate;
screenshareBitrate: ScreenshareBitrate;
screenshareFramerate: ScreenshareFramerate;
};
/**
* Build the `io.lotus.set_quality` payload from the user's settings, clamped by
* any room-level cap. Every field is always present so clearing a setting back
* to 'auto' sends an explicit `null` that resets the fork-side cap (otherwise a
* previously-applied cap would stick for the rest of the call).
*/
export const buildQualityPayload = (
settings: QualitySettings,
roomCaps?: RoomQualityContent,
): LotusQualityPayload => {
const userAudio = num(settings.callAudioBitrate);
const userSsBitrate = num(settings.screenshareBitrate);
const userSsFps = num(settings.screenshareFramerate);
const audioKbps = minCap(userAudio, roomCaps?.audio_max_kbps);
const ssKbps = minCap(userSsBitrate, roomCaps?.screenshare_max_kbps);
const ssFps = minCap(userSsFps, roomCaps?.screenshare_max_fps);
return {
audioMaxBitrate: audioKbps === undefined ? null : audioKbps * 1000,
screenshareMaxBitrate: ssKbps === undefined ? null : ssKbps * 1000,
screenshareMaxFramerate: ssFps === undefined ? null : ssFps,
};
};
+72
View File
@@ -0,0 +1,72 @@
import { MatrixClient } from 'matrix-js-sdk';
import { downloadMedia, mxcUrlToHttp } from './matrix';
/**
* [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the
* `io.lotus.soundboard` account data event, so clips sync across a user's
* devices exactly like custom emoji / sticker packs.
*/
export type SoundboardClip = {
/** Stable local id (not shared with peers). */
id: string;
/** Display name / shortcode shown on the tile. */
name: string;
/** mxc:// URI of the uploaded audio. */
url: string;
mimetype?: string;
size?: number;
};
export type SoundboardContent = {
clips?: SoundboardClip[];
};
export const SOUNDBOARD_NAME_MAX = 24;
/** Keep clips short: they publish to every peer and hold a track open. */
export const SOUNDBOARD_MAX_CLIP_BYTES = 1024 * 1024; // 1 MB
export const SOUNDBOARD_MAX_CLIPS = 40;
export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac';
// Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download
// it. Object URLs live for the page session; the set is tiny (<= MAX_CLIPS).
const objectUrlCache = new Map<string, string>();
/**
* Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch
* without credentials. Authenticated media (MSC3916) can't be fetched from the
* widget's realm, so the host downloads it (auth handled by the service worker)
* and hands the widget a same-session blob URL instead.
*/
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
const cached = objectUrlCache.get(mxcUrl);
if (cached) return cached;
const httpUrl = mxcUrlToHttp(mx, mxcUrl, true);
if (!httpUrl) throw new Error('invalid mxc url');
const blob = await downloadMedia(httpUrl);
const objectUrl = URL.createObjectURL(blob);
objectUrlCache.set(mxcUrl, objectUrl);
return objectUrl;
};
/**
* Play a resolved clip locally so the person who pressed it gets immediate
* feedback — LiveKit doesn't loop a participant's own published track back to
* them, so without this the presser would hear nothing. `volume` is 01.
*/
export const playClipLocally = (objectUrl: string, volume: number): void => {
try {
const audio = new Audio(objectUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.play().catch(() => undefined);
} catch {
/* best effort */
}
};
export const readSoundboardClips = (mx: MatrixClient): SoundboardClip[] => {
const content = mx.getAccountData('io.lotus.soundboard' as never)?.getContent() as
| SoundboardContent
| undefined;
return Array.isArray(content?.clips) ? content.clips : [];
};
+4
View File
@@ -10,6 +10,10 @@ export enum AccountDataEvent {
PoniesUserEmotes = 'im.ponies.user_emotes',
PoniesEmoteRooms = 'im.ponies.emote_rooms',
// [P5-15] Personal, uploadable in-call soundboard clips (synced across
// devices like custom emoji/sticker packs).
LotusSoundboard = 'io.lotus.soundboard',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
CrossSigningMaster = 'm.cross_signing.master',
+1
View File
@@ -41,6 +41,7 @@ export enum StateEvent {
PoniesRoomEmotes = 'im.ponies.room_emotes',
PowerLevelTags = 'in.cinny.room.power_level_tags',
LotusVoiceLimit = 'io.lotus.voice_limit',
LotusRoomQuality = 'io.lotus.room_quality',
}
export enum MessageEvent {