/* 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 = {}; // [lotus] Tracks which RTCRtpEncodingParameters keys this module has // actively written on each sender, so a later `null` (clear) can write // `undefined` into those same keys instead of just dropping the sticky // setting and leaving the stale cap live on the sender (#11). const writtenKeys = new WeakMap< RTCRtpSender, Set >(); // Per-room LocalTrackPublished listeners, so sticky settings re-apply on // every (re)publish. const roomListeners = new Map 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>(); let rooms: LivekitRoom[] = []; const applyToRoom = (room: LivekitRoom): void => { const lp = room.localParticipant; const mic = lp.getTrackPublication(Track.Source.Microphone)?.track as | LocalTrack | undefined; const micDesired: Partial = {}; if (settings.audioMaxBitrate !== undefined) micDesired.maxBitrate = settings.audioMaxBitrate; const micPatch = buildPatch(mic?.sender, micDesired, writtenKeys); if (Object.keys(micPatch).length > 0) void patchSender(mic?.sender, micPatch, writtenKeys); const ssDesired: Partial = {}; if (settings.screenshareMaxBitrate !== undefined) ssDesired.maxBitrate = settings.screenshareMaxBitrate; if (settings.screenshareMaxFramerate !== undefined) ssDesired.maxFramerate = settings.screenshareMaxFramerate; const ss = lp.getTrackPublication(Track.Source.ScreenShare)?.track as | LocalTrack | undefined; const ssPatch = buildPatch(ss?.sender, ssDesired, writtenKeys); if (Object.keys(ssPatch).length > 0) void patchSender(ss?.sender, ssPatch, writtenKeys); }; 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): void => { w.api.transport.reply(ev.detail, {}); const data = ev.detail.data as Record | 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 = { 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); }; } // [lotus] Build the patch to actually send to a sender: the desired caps, // plus an explicit `undefined` for any key this module previously wrote to // this sender but no longer wants (see writtenKeys / #11) — otherwise a // cleared sticky setting would simply be skipped here and the stale // maxBitrate/maxFramerate would stay live on the RTCRtpSender. // Exported for unit testing only (see lotusQuality.test.ts) — not part of // the module's public surface used by callers. export function buildPatch( sender: RTCRtpSender | undefined, desired: Partial, writtenKeys: WeakMap>, ): Partial { if (!sender) return {}; const patch: Partial = { ...desired }; const prev = writtenKeys.get(sender); if (prev) { for (const key of prev) { if (!(key in patch)) patch[key] = undefined; } } return patch; } // Exported for unit testing only (see lotusQuality.test.ts) — not part of // the module's public surface used by callers. export async function patchSender( sender: RTCRtpSender | undefined, patch: Partial, writtenKeys: WeakMap>, ): Promise { 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); // Remember only the caps that are still active (defined) after this // write, so a later clear knows exactly which keys to unset. const active = new Set(); for (const [key, value] of Object.entries(patch)) { if (value !== undefined) active.add(key as keyof RTCRtpEncodingParameters); } if (active.size > 0) writtenKeys.set(sender, active); else writtenKeys.delete(sender); } catch (e) { logger.warn("[lotus] set_quality: setParameters failed", e); } }