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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 0–1.
|
||||
*/
|
||||
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 : [];
|
||||
};
|
||||
Reference in New Issue
Block a user