diff --git a/src/lotus/lotusQuality.ts b/src/lotus/lotusQuality.ts index d35d604c..5fcea4ce 100644 --- a/src/lotus/lotusQuality.ts +++ b/src/lotus/lotusQuality.ts @@ -89,17 +89,24 @@ export function startLotusQuality(vm: CallViewModel): () => void { // Add listeners for new rooms + apply current settings to them. for (const room of next) { if (!roomListeners.has(room)) { - const onPublished = (): void => applyToRoom(room); - room.localParticipant.on( + // 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); + setTimeout(() => applyToRoom(room), 500); + }; + const events = [ ParticipantEvent.LocalTrackPublished, - onPublished, - ); - roomListeners.set(room, () => - room.localParticipant.off( - ParticipantEvent.LocalTrackPublished, - onPublished, - ), - ); + 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); + }); applyToRoom(room); } } @@ -109,14 +116,19 @@ export function startLotusQuality(vm: CallViewModel): () => void { void w.api.transport.reply(ev.detail, {}); const data = ev.detail.data as Record | undefined; if (!data) return; - for (const key of [ - "audioMaxBitrate", - "screenshareMaxBitrate", - "screenshareMaxFramerate", - ] as const) { + // 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" && v > 0) settings[key] = v; + else if (typeof v === "number" && Number.isFinite(v)) { + const [lo, hi] = ranges[key]; + settings[key] = Math.min(hi, Math.max(lo, v)); + } } applyToAll(); }; @@ -139,7 +151,10 @@ async function patchSender( const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) params.encodings = [{}]; - Object.assign(params.encodings[0], patch); + // 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);