2026-07-02 14:12:08 -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 RemoteParticipant,
|
|
|
|
|
type Room as LivekitRoom,
|
|
|
|
|
RoomEvent,
|
|
|
|
|
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";
|
2026-09-12 11:34:08 -04:00
|
|
|
import { setAudioEnabled$ } from "../controls";
|
2026-07-02 14:12:08 -04:00
|
|
|
import { widget } from "../widget";
|
|
|
|
|
import { LotusWidgetActions } from "./lotusActions";
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-12 11:34:08 -04:00
|
|
|
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
|
|
|
|
* brittle iframe-DOM `.muted` hack (which fought MatrixAudioRenderer and broke
|
|
|
|
|
* on re-render / late tracks).
|
2026-07-02 14:12:08 -04:00
|
|
|
*
|
2026-09-12 11:34:08 -04:00
|
|
|
* `deafened` drives EC's OWN global audio output mute: `setAudioEnabled$` feeds
|
|
|
|
|
* `muteAllAudio$` (`src/state/MuteAllAudioModel.ts`), which `InCallView` already
|
|
|
|
|
* passes as `muted` to every `LivekitRoomAudioRenderer` (and hence
|
|
|
|
|
* `MatrixAudioRenderer`), plus `CallEventAudioRenderer` and
|
|
|
|
|
* `ReactionsAudioRenderer`. That silences EVERY remote source — microphone,
|
|
|
|
|
* screenshare audio and `Track.Source.Unknown` soundboard clips — and needs no
|
|
|
|
|
* per-participant bookkeeping for late joiners or reconnects.
|
2026-07-02 14:12:08 -04:00
|
|
|
*
|
2026-09-12 11:34:08 -04:00
|
|
|
* This deliberately does NOT use `RemoteParticipant.setVolume` for deafen any
|
|
|
|
|
* more: EC's per-participant volume slider / per-tile mute writes the same
|
|
|
|
|
* `volumeMap` from `createVolumeControls` (`src/state/VolumeControls.ts`)
|
|
|
|
|
* whenever its `sink$` re-emits (every join, every participant re-resolution),
|
|
|
|
|
* so a `setVolume(0)` deafen was silently undone for anyone joining while
|
|
|
|
|
* deafened, and an undeafen `setVolume(1)` clobbered the user's own per-tile
|
|
|
|
|
* volume/mute state.
|
|
|
|
|
*
|
|
|
|
|
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
|
|
|
|
* tab/game audio while still hearing voices). EC has no global per-source mute,
|
|
|
|
|
* so that one still has to go through
|
|
|
|
|
* `RemoteParticipant.setVolume(volume, Track.Source.ScreenShareAudio)` — whose
|
|
|
|
|
* verified signature in livekit-client ^2.18.1 is
|
|
|
|
|
* `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`.
|
|
|
|
|
* It is re-applied to late joiners via `RoomEvent.ParticipantConnected`, and we
|
|
|
|
|
* only ever restore participants we muted ourselves. Known limitation: EC's own
|
|
|
|
|
* screenshare volume slider writes the same `volumeMap`, so a user who moves
|
|
|
|
|
* that slider while screenshare audio is host-muted wins; the mic path (the
|
|
|
|
|
* actual deafen) is no longer affected by that race at all.
|
|
|
|
|
*
|
|
|
|
|
* Undeafen restores the user's OWN output-enabled state as it was before the
|
|
|
|
|
* deafen (and never touches the `mute-all-audio` setting), so a user who had
|
|
|
|
|
* already muted all audio themselves stays muted.
|
|
|
|
|
*
|
|
|
|
|
* State is closure-scoped (per invocation, matching the sibling lotus modules).
|
|
|
|
|
* Applying the same state twice is a no-op, so the host's
|
|
|
|
|
* `CallControl.resendForkState()` after a reconnect is safe. The host re-sends
|
|
|
|
|
* the current state on every call join (CallControl.forceState), so a fresh
|
|
|
|
|
* call never inherits a previous call's deafen state.
|
2026-07-02 14:12:08 -04:00
|
|
|
*
|
|
|
|
|
* No effect unless the host sends the action. Returns a teardown function.
|
|
|
|
|
*/
|
|
|
|
|
export function startLotusDeafen(vm: CallViewModel): () => void {
|
|
|
|
|
const w = widget;
|
|
|
|
|
if (!w) return () => undefined;
|
|
|
|
|
|
|
|
|
|
let deafened = false;
|
|
|
|
|
let screenshareAudioMuted = false;
|
|
|
|
|
|
2026-09-12 11:34:08 -04:00
|
|
|
// The user's own audio-output state, tracked from `setAudioEnabled$` so an
|
|
|
|
|
// undeafen restores it rather than blindly enabling output. Defaults to
|
|
|
|
|
// `true`, matching `muteAllAudio$`'s `startWith(true)`.
|
|
|
|
|
let userAudioEnabled = true;
|
|
|
|
|
// Ignore our own (synchronous) emissions while tracking the user's state, and
|
|
|
|
|
// skip pushes that would not change anything so re-applying the same state
|
|
|
|
|
// (the host's `resendForkState()` after a reconnect) is a no-op.
|
|
|
|
|
let selfEmitting = false;
|
|
|
|
|
let lastApplied: boolean | null = null;
|
|
|
|
|
|
|
|
|
|
const applyGlobalMute = (): void => {
|
|
|
|
|
const desired = deafened ? false : userAudioEnabled;
|
|
|
|
|
// `lastApplied` is our own override, if any; otherwise the live state is
|
|
|
|
|
// whatever the user last set.
|
|
|
|
|
const current = lastApplied ?? userAudioEnabled;
|
|
|
|
|
lastApplied = desired;
|
|
|
|
|
if (desired === current) return;
|
|
|
|
|
selfEmitting = true;
|
|
|
|
|
try {
|
|
|
|
|
setAudioEnabled$.next(desired);
|
|
|
|
|
} finally {
|
|
|
|
|
selfEmitting = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const audioSub = setAudioEnabled$.subscribe((enabled) => {
|
|
|
|
|
if (selfEmitting) return;
|
|
|
|
|
userAudioEnabled = enabled;
|
|
|
|
|
if (deafened && enabled) {
|
|
|
|
|
// Something else (the native output controls) re-enabled audio while
|
|
|
|
|
// deafened — re-assert the mute instead of silently losing deafen.
|
|
|
|
|
lastApplied = true;
|
|
|
|
|
applyGlobalMute();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Participants whose screenshare audio WE turned down, so undoing the host's
|
|
|
|
|
// screenshare mute never writes a volume to anyone else.
|
|
|
|
|
const screenshareMuted = new Set<RemoteParticipant>();
|
|
|
|
|
|
2026-07-02 14:12:08 -04:00
|
|
|
const applyToParticipant = (p: RemoteParticipant): void => {
|
2026-09-12 11:34:08 -04:00
|
|
|
if (screenshareAudioMuted) {
|
|
|
|
|
p.setVolume(0, Track.Source.ScreenShareAudio);
|
|
|
|
|
screenshareMuted.add(p);
|
|
|
|
|
} else if (screenshareMuted.delete(p)) {
|
|
|
|
|
p.setVolume(1, Track.Source.ScreenShareAudio);
|
|
|
|
|
}
|
2026-07-02 14:12:08 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyToRoom = (room: LivekitRoom): void =>
|
|
|
|
|
room.remoteParticipants.forEach(applyToParticipant);
|
|
|
|
|
|
|
|
|
|
// Per-room ParticipantConnected listeners, so LATE JOINERS pick up the
|
2026-09-12 11:34:08 -04:00
|
|
|
// current screenshare-audio mute the moment they connect. Drive off the
|
|
|
|
|
// local participant's connection(s), not `livekitRoomItems$` — that stream is
|
|
|
|
|
// empty until a matrix-validated REMOTE member resolves, so listeners would
|
|
|
|
|
// be attached too late for the first joiner (the sibling lotus modules all
|
|
|
|
|
// use `allConnections$` for the same reason).
|
2026-07-02 14:12:08 -04:00
|
|
|
const roomListeners = new Map<LivekitRoom, () => void>();
|
|
|
|
|
let rooms: LivekitRoom[] = [];
|
|
|
|
|
|
2026-09-12 11:34:08 -04:00
|
|
|
const sub = vm.allConnections$.subscribe((data) => {
|
|
|
|
|
const next = data.getConnections().map((c) => c.livekitRoom);
|
2026-07-02 14:12:08 -04:00
|
|
|
rooms = next;
|
|
|
|
|
// Detach listeners for rooms that went away.
|
|
|
|
|
for (const [room, off] of roomListeners) {
|
|
|
|
|
if (!next.includes(room)) {
|
|
|
|
|
off();
|
|
|
|
|
roomListeners.delete(room);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Attach to new rooms + apply the current state to their participants.
|
|
|
|
|
for (const room of next) {
|
|
|
|
|
if (!roomListeners.has(room)) {
|
|
|
|
|
room.on(RoomEvent.ParticipantConnected, applyToParticipant);
|
|
|
|
|
roomListeners.set(room, () =>
|
|
|
|
|
room.off(RoomEvent.ParticipantConnected, applyToParticipant),
|
|
|
|
|
);
|
|
|
|
|
applyToRoom(room);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
2026-09-12 11:35:57 -04:00
|
|
|
w.api.transport.reply(ev.detail, {});
|
2026-07-02 14:12:08 -04:00
|
|
|
const data = ev.detail.data as
|
|
|
|
|
| { deafened?: boolean; screenshareAudioMuted?: boolean }
|
|
|
|
|
| undefined;
|
|
|
|
|
// Missing fields default to their CURRENT value, so a partial payload only
|
|
|
|
|
// moves the flag it actually names.
|
|
|
|
|
if (typeof data?.deafened === "boolean") deafened = data.deafened;
|
|
|
|
|
if (typeof data?.screenshareAudioMuted === "boolean")
|
|
|
|
|
screenshareAudioMuted = data.screenshareAudioMuted;
|
|
|
|
|
logger.debug(
|
|
|
|
|
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
|
|
|
|
);
|
2026-09-12 11:34:08 -04:00
|
|
|
applyGlobalMute();
|
2026-07-02 14:12:08 -04:00
|
|
|
rooms.forEach(applyToRoom);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
|
|
|
|
return () => {
|
|
|
|
|
sub.unsubscribe();
|
|
|
|
|
for (const off of roomListeners.values()) off();
|
|
|
|
|
roomListeners.clear();
|
2026-09-12 11:34:08 -04:00
|
|
|
// Leave the user's own output state as they had it before deafen.
|
|
|
|
|
if (deafened) {
|
|
|
|
|
deafened = false;
|
|
|
|
|
applyGlobalMute();
|
|
|
|
|
}
|
|
|
|
|
screenshareMuted.clear();
|
|
|
|
|
audioSub.unsubscribe();
|
2026-07-02 14:12:08 -04:00
|
|
|
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
|
|
|
|
};
|
|
|
|
|
}
|