Files
element-call/src/lotus/lotusQuality.ts
T
Lotus CIandClaude Opus 4.8 0ffe247929
CI / Build embedded bundle (push) Successful in 1m0s
CI / Publish to Gitea npm registry (push) Has been skipped
fix(lotus): Wave-1 audit fixes (EC1–EC6)
- EC1: lotusQuality — track + clearTimeout the 500ms settle re-apply per room
  (was leaking a timer that fired on torn-down rooms).
- EC2/EC3: lotusQuality + lotusAudioInject drive off vm.allConnections$ instead
  of the remote-gated livekitRoomItems$ (were no-ops when alone), matching
  lotusDenoise.
- EC4: lotusDecorations resets its roster to {} on teardown so a decoration from
  a previous call can't render on a shared user in the next one.
- EC5: hoisted a stable useSyncExternalStore subscribe fn (was re-subscribing
  every tile render).
- EC6: lotusFocus only sets the spotlight when the userId field is present
  (a partial payload no longer clears the pin).

tsc clean. Needs a republish to ship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:13:01 -04:00

185 lines
7.0 KiB
TypeScript

/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrack,
ParticipantEvent,
type Room as LivekitRoom,
Track,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { widget } from "../widget";
import { LotusWidgetActions } from "./lotusActions";
interface QualitySettings {
/** Max audio (mic) bitrate in bits/sec. */
audioMaxBitrate?: number;
/** Max screenshare video bitrate in bits/sec. */
screenshareMaxBitrate?: number;
/** Max screenshare framerate in fps. */
screenshareMaxFramerate?: number;
}
/**
* Handle the host's `io.lotus.set_quality` toWidget action (#7): apply
* audio/screenshare encoding limits (bitrate, framerate) to the local
* published tracks via `RTCRtpSender.setParameters` — no republish needed.
* These controls live in EC's module scope and were unreachable from the host
* against the prebuilt bundle.
*
* Settings are sticky and re-applied whenever a matching local track is
* (re)published, so they survive mute/unmute and reconnects. The server-side
* voice-limit-guard remains the enforcement backstop.
*
* No effect unless the host sends the action. Returns a teardown function.
*/
export function startLotusQuality(vm: CallViewModel): () => void {
const w = widget;
if (!w) return () => undefined;
const settings: QualitySettings = {};
// Per-room LocalTrackPublished listeners, so sticky settings re-apply on
// every (re)publish.
const roomListeners = new Map<LivekitRoom, () => void>();
// Per-room settle re-apply timers, so we can cancel a pending 500ms re-apply
// when a room is removed or on teardown — otherwise it would fire against a
// torn-down room.
const settleTimers = new Map<LivekitRoom, ReturnType<typeof setTimeout>>();
let rooms: LivekitRoom[] = [];
const applyToRoom = (room: LivekitRoom): void => {
const lp = room.localParticipant;
if (settings.audioMaxBitrate !== undefined) {
const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as
| LocalTrack
| undefined;
void patchSender(mic?.sender, { maxBitrate: settings.audioMaxBitrate });
}
const ssPatch: Partial<RTCRtpEncodingParameters> = {};
if (settings.screenshareMaxBitrate !== undefined)
ssPatch.maxBitrate = settings.screenshareMaxBitrate;
if (settings.screenshareMaxFramerate !== undefined)
ssPatch.maxFramerate = settings.screenshareMaxFramerate;
if (Object.keys(ssPatch).length > 0) {
const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as
| LocalTrack
| undefined;
void patchSender(ss?.sender, ssPatch);
}
};
const applyToAll = (): void => rooms.forEach(applyToRoom);
// Keep the LocalTrackPublished listeners in sync with the connected rooms.
// Drive off the LOCAL participant's connection(s), not `livekitRoomItems$` —
// that stream omits rooms with no remote members (returns null for isLocal),
// so caps wouldn't apply to the local senders while you're alone. Map the
// connections to their livekit rooms exactly like `lotusDenoise.ts` does.
const sub = vm.allConnections$.subscribe((data) => {
const next = data.getConnections().map((c) => c.livekitRoom);
rooms = next;
// Remove listeners for rooms that went away.
for (const [room, off] of roomListeners) {
if (!next.includes(room)) {
off();
roomListeners.delete(room);
}
}
// Add listeners for new rooms + apply current settings to them.
for (const room of next) {
if (!roomListeners.has(room)) {
// Re-apply on (re)publish AND unmute/track-restart: LiveKit's
// refreshSenderEncodings() overwrites maxBitrate/maxFramerate from the
// publish presets on replaceTrack (device/source switch, processor
// toggle, restart-on-unmute), and those paths don't emit
// LocalTrackPublished. The settle re-apply lands after LiveKit's async
// recompute so our cap wins.
const reapply = (): void => {
applyToRoom(room);
// Store the settle timer per room and cancel any pending one, so it
// can be cleared on removal/teardown and never fires against a
// torn-down room.
const prev = settleTimers.get(room);
if (prev !== undefined) clearTimeout(prev);
settleTimers.set(
room,
setTimeout(() => {
settleTimers.delete(room);
applyToRoom(room);
}, 500),
);
};
const events = [
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.TrackUnmuted,
] as const;
for (const e of events) room.localParticipant.on(e, reapply);
roomListeners.set(room, () => {
for (const e of events) room.localParticipant.off(e, reapply);
const t = settleTimers.get(room);
if (t !== undefined) clearTimeout(t);
settleTimers.delete(room);
});
applyToRoom(room);
}
}
});
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
void w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as Record<string, unknown> | undefined;
if (!data) return;
// Clamp to sane ranges so a typo can't brick the encoder (e.g. a 1 bps mic).
const ranges: Record<keyof QualitySettings, [number, number]> = {
audioMaxBitrate: [6_000, 510_000],
screenshareMaxBitrate: [50_000, 20_000_000],
screenshareMaxFramerate: [1, 60],
};
for (const key of Object.keys(ranges) as (keyof QualitySettings)[]) {
const v = data[key];
if (v === null) settings[key] = undefined;
else if (typeof v === "number" && Number.isFinite(v)) {
const [lo, hi] = ranges[key];
settings[key] = Math.min(hi, Math.max(lo, v));
}
}
applyToAll();
};
w.lazyActions.on(LotusWidgetActions.SetQuality, handler);
return () => {
sub.unsubscribe();
for (const off of roomListeners.values()) off();
roomListeners.clear();
w.lazyActions.off(LotusWidgetActions.SetQuality, handler);
};
}
async function patchSender(
sender: RTCRtpSender | undefined,
patch: Partial<RTCRtpEncodingParameters>,
): Promise<void> {
if (!sender) return;
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0)
params.encodings = [{}];
// Apply to EVERY encoding, not just encodings[0]: screenshare publishes
// with simulcast (VP8), so encodings[0] is the small layer and the
// full-resolution layer — the real bandwidth hog — is a later encoding.
for (const enc of params.encodings) Object.assign(enc, patch);
await sender.setParameters(params);
} catch (e) {
logger.warn("[lotus] set_quality: setParameters failed", e);
}
}