2026-06-29 23:24:57 -04:00
|
|
|
/*
|
|
|
|
|
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>();
|
2026-07-02 20:13:01 -04:00
|
|
|
// 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>>();
|
2026-06-29 23:24:57 -04:00
|
|
|
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.
|
2026-07-02 20:13:01 -04:00
|
|
|
// 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);
|
2026-06-29 23:24:57 -04:00
|
|
|
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)) {
|
2026-06-29 23:38:45 -04:00
|
|
|
// 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);
|
2026-07-02 20:13:01 -04:00
|
|
|
// 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),
|
|
|
|
|
);
|
2026-06-29 23:38:45 -04:00
|
|
|
};
|
|
|
|
|
const events = [
|
2026-06-29 23:24:57 -04:00
|
|
|
ParticipantEvent.LocalTrackPublished,
|
2026-06-29 23:38:45 -04:00
|
|
|
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);
|
2026-07-02 20:13:01 -04:00
|
|
|
const t = settleTimers.get(room);
|
|
|
|
|
if (t !== undefined) clearTimeout(t);
|
|
|
|
|
settleTimers.delete(room);
|
2026-06-29 23:38:45 -04:00
|
|
|
});
|
2026-06-29 23:24:57 -04:00
|
|
|
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;
|
2026-06-29 23:38:45 -04:00
|
|
|
// 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)[]) {
|
2026-06-29 23:24:57 -04:00
|
|
|
const v = data[key];
|
|
|
|
|
if (v === null) settings[key] = undefined;
|
2026-06-29 23:38:45 -04:00
|
|
|
else if (typeof v === "number" && Number.isFinite(v)) {
|
|
|
|
|
const [lo, hi] = ranges[key];
|
|
|
|
|
settings[key] = Math.min(hi, Math.max(lo, v));
|
|
|
|
|
}
|
2026-06-29 23:24:57 -04:00
|
|
|
}
|
|
|
|
|
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 = [{}];
|
2026-06-29 23:38:45 -04:00
|
|
|
// 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);
|
2026-06-29 23:24:57 -04:00
|
|
|
await sender.setParameters(params);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn("[lotus] set_quality: setParameters failed", e);
|
|
|
|
|
}
|
|
|
|
|
}
|