Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667230f6e3 | ||
|
|
746917a4c6 | ||
|
|
d881833491 | ||
|
|
021b1881e5 | ||
|
|
1b609d997b | ||
|
|
d9ac9a0fa4 | ||
|
|
66bfead7f1 | ||
|
|
ea579cb998 | ||
|
|
bc0e5ed432 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lotusguild/element-call-embedded",
|
||||
"version": "0.25.0-lotus.2",
|
||||
"version": "0.25.0-lotus.7",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-AGPL-3.0",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
|
||||
"room_creation_restricted": "Failed to create call",
|
||||
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
|
||||
"sfu_token_refused": "Can't join this call",
|
||||
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
|
||||
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
|
||||
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
|
||||
|
||||
@@ -52,9 +52,16 @@ afterEach(() => {
|
||||
vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
||||
AudioTrack: (props: {
|
||||
trackRef: TrackReference;
|
||||
muted?: boolean;
|
||||
}): ReactNode => {
|
||||
return (
|
||||
<audio data-testid={"audio"}>
|
||||
<audio
|
||||
data-testid={"audio"}
|
||||
data-source={props.trackRef.publication.source}
|
||||
data-muted={String(!!props.muted)}
|
||||
>
|
||||
{getTrackReferenceId(props.trackRef)}
|
||||
</audio>
|
||||
);
|
||||
@@ -83,6 +90,7 @@ function renderTestComponent(
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[],
|
||||
props: { muted?: boolean; screenshareAudioMuted?: boolean } = {},
|
||||
): RenderResult {
|
||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||
mockRemoteParticipant({ identity }),
|
||||
@@ -117,6 +125,7 @@ function renderTestComponent(
|
||||
validIdentities={participants.map((p) => p.identity)}
|
||||
livekitRoom={livekitRoom}
|
||||
url={""}
|
||||
{...props}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
@@ -286,3 +295,55 @@ it("should setup audioContext gain and pan", () => {
|
||||
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(1);
|
||||
});
|
||||
|
||||
// [lotus] The host's "Mute Screenshare Audio" mutes ONLY screenshare-audio
|
||||
// elements, via the same `muted` prop path as deafen (so it survives re-renders
|
||||
// and later-published shares); deafen still mutes everything.
|
||||
it("screenshareAudioMuted mutes only ScreenShareAudio tracks", () => {
|
||||
const explicitTracks = [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
},
|
||||
];
|
||||
const mutedBySource = (r: RenderResult): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
r
|
||||
.queryAllByTestId("audio")
|
||||
.map((el) => [el.dataset.source, el.dataset.muted]),
|
||||
);
|
||||
|
||||
expect(
|
||||
mutedBySource(
|
||||
renderTestComponent(
|
||||
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
explicitTracks,
|
||||
{ screenshareAudioMuted: true },
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
[Track.Source.Microphone]: "false",
|
||||
[Track.Source.ScreenShareAudio]: "true",
|
||||
});
|
||||
|
||||
expect(
|
||||
mutedBySource(
|
||||
renderTestComponent(
|
||||
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
explicitTracks,
|
||||
{ muted: true },
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
[Track.Source.Microphone]: "true",
|
||||
[Track.Source.ScreenShareAudio]: "true",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface MatrixAudioRendererProps {
|
||||
* If set to `true`, the server will stop sending audio track data to the client.
|
||||
*/
|
||||
muted?: boolean;
|
||||
/**
|
||||
* [lotus] If set to `true`, mutes only the `Track.Source.ScreenShareAudio`
|
||||
* tracks (the host's "Mute Screenshare Audio" control) — same mechanism as
|
||||
* `muted`, so it holds across re-renders and later-published shares.
|
||||
*/
|
||||
screenshareAudioMuted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +64,7 @@ export function LivekitRoomAudioRenderer({
|
||||
livekitRoom,
|
||||
validIdentities,
|
||||
muted,
|
||||
screenshareAudioMuted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
const tracks = useTracks(
|
||||
@@ -143,7 +150,11 @@ export function LivekitRoomAudioRenderer({
|
||||
<AudioTrackWithAudioNodes
|
||||
key={getTrackReferenceId(trackRef)}
|
||||
trackRef={trackRef}
|
||||
muted={muted}
|
||||
muted={
|
||||
muted ||
|
||||
(screenshareAudioMuted &&
|
||||
trackRef.publication.source === Track.Source.ScreenShareAudio)
|
||||
}
|
||||
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
||||
audioNodes={audioNodes}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { MatrixError } from "matrix-js-sdk";
|
||||
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||
import { testJWTToken } from "../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../utils/test";
|
||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||
import { FailToGetOpenIdToken, SFUTokenRefusedError } from "../utils/errors";
|
||||
|
||||
const sfuUrl = "https://sfu.example.org";
|
||||
|
||||
@@ -91,6 +91,31 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
expect.fail("Expected test to throw;");
|
||||
});
|
||||
|
||||
it("[lotus] surfaces a 403 refusal's reason instead of the generic error", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: "M_FORBIDDEN", error: "This voice channel is full." },
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
);
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(SFUTokenRefusedError);
|
||||
expect((ex as SFUTokenRefusedError).localisedMessage).toEqual(
|
||||
"This voice channel is full.",
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
return;
|
||||
}
|
||||
expect.fail("Expected test to throw;");
|
||||
});
|
||||
|
||||
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||
let callCount = 0;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
MatrixError,
|
||||
parseErrorResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -16,6 +17,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
NoMatrix2AuthorizationService,
|
||||
SFUTokenRefusedError,
|
||||
} from "../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -165,6 +167,14 @@ export async function getSFUConfigWithOpenID(
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (ex) {
|
||||
// [lotus] A 403 from the token service carries the reason the user needs
|
||||
// ("This voice channel is full.") — surface it instead of the generic error.
|
||||
if (ex instanceof MatrixError && ex.httpStatus === 403) {
|
||||
const reason = (ex.data as { error?: unknown } | undefined)?.error;
|
||||
if (typeof reason === "string" && reason.trim()) {
|
||||
throw new SFUTokenRefusedError(reason, ex);
|
||||
}
|
||||
}
|
||||
throw new FailToGetOpenIdToken(
|
||||
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
|
||||
);
|
||||
|
||||
@@ -88,6 +88,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("[lotus] audio-capture URL param overrides", () => {
|
||||
test("mic capture is always requested mono (stereo interfaces on Firefox published L-only)", () => {
|
||||
getUrlParams.mockReturnValue({
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
});
|
||||
createRoom();
|
||||
expect(capturedAudioDefaults()).toMatchObject({ channelCount: 1 });
|
||||
});
|
||||
|
||||
test("with params defaulted to true, the Settings govern (upstream behaviour)", () => {
|
||||
getUrlParams.mockReturnValue({
|
||||
echoCancellation: true,
|
||||
|
||||
@@ -7,13 +7,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { of } from "rxjs";
|
||||
import { Track } from "livekit-client";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { startLotusDeafen } from "./lotusDeafen";
|
||||
import { LotusWidgetActions } from "./lotusActions";
|
||||
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||
|
||||
const lazyActions = new EventEmitter();
|
||||
|
||||
@@ -27,18 +25,6 @@ vi.mock("../widget", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/** Minimal CallViewModel stub: no connections, so no livekit rooms. */
|
||||
function mockVm(participants: unknown[] = []): CallViewModel {
|
||||
const livekitRoom = {
|
||||
remoteParticipants: new Map(participants.map((p, i) => [String(i), p])),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
return {
|
||||
allConnections$: of({ getConnections: () => [{ livekitRoom }] }),
|
||||
} as unknown as CallViewModel;
|
||||
}
|
||||
|
||||
function send(data: unknown): void {
|
||||
lazyActions.emit(LotusWidgetActions.SetDeafen, {
|
||||
detail: { data },
|
||||
@@ -60,7 +46,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
||||
const stop = startLotusDeafen(mockVm());
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
send({ deafened: true, screenshareAudioMuted: false });
|
||||
expect(emissions).toEqual([false]);
|
||||
@@ -73,7 +59,7 @@ test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
||||
});
|
||||
|
||||
test("re-sending the same state is idempotent (host resendForkState)", () => {
|
||||
const stop = startLotusDeafen(mockVm());
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
send({ deafened: true, screenshareAudioMuted: false });
|
||||
send({ deafened: true, screenshareAudioMuted: false });
|
||||
@@ -86,7 +72,7 @@ test("re-sending the same state is idempotent (host resendForkState)", () => {
|
||||
});
|
||||
|
||||
test("undeafen does not re-enable audio the user had muted themselves", () => {
|
||||
const stop = startLotusDeafen(mockVm());
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
// The user mutes all audio through EC's own control first.
|
||||
setAudioEnabled$.next(false);
|
||||
@@ -101,48 +87,44 @@ test("undeafen does not re-enable audio the user had muted themselves", () => {
|
||||
expect(emissions).toEqual([false]);
|
||||
});
|
||||
|
||||
test("deafen never touches per-participant volume", () => {
|
||||
const participant = { setVolume: vi.fn() };
|
||||
const stop = startLotusDeafen(mockVm([participant]));
|
||||
test("deafen never touches the screenshare-audio flag", () => {
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
send({ deafened: true, screenshareAudioMuted: false });
|
||||
send({ deafened: false, screenshareAudioMuted: false });
|
||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
||||
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
test("screenshare-audio mute is applied per source and only undone for participants we muted", () => {
|
||||
const participant = { setVolume: vi.fn() };
|
||||
const stop = startLotusDeafen(mockVm([participant]));
|
||||
test("screenshare-audio mute is a global flag the renderer reads, reset on teardown", () => {
|
||||
const seen: boolean[] = [];
|
||||
const flagSub = setScreenshareAudioMuted$.subscribe((v) => seen.push(v));
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
// Not muted yet: no volume writes at all.
|
||||
// Not muted yet: nothing pushed beyond the BehaviorSubject's initial value.
|
||||
send({ deafened: false, screenshareAudioMuted: false });
|
||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
||||
expect(seen).toEqual([false]);
|
||||
|
||||
send({ deafened: false, screenshareAudioMuted: true });
|
||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||
// Re-applying the same state (host resend after reconnect) is a no-op.
|
||||
send({ deafened: false, screenshareAudioMuted: true });
|
||||
expect(seen).toEqual([false, true]);
|
||||
|
||||
participant.setVolume.mockClear();
|
||||
send({ deafened: false, screenshareAudioMuted: false });
|
||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
||||
1,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
|
||||
// Releasing again writes nothing: we no longer own that participant.
|
||||
participant.setVolume.mockClear();
|
||||
send({ deafened: false, screenshareAudioMuted: false });
|
||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
||||
// Deafen + undeafen while screenshare audio is muted leaves it muted.
|
||||
send({ deafened: true, screenshareAudioMuted: true });
|
||||
send({ deafened: false, screenshareAudioMuted: true });
|
||||
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||
|
||||
// Leaving the call clears it so the next call starts clean.
|
||||
stop();
|
||||
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||
flagSub.unsubscribe();
|
||||
});
|
||||
|
||||
test("a partial payload only moves the flag it names", () => {
|
||||
const stop = startLotusDeafen(mockVm());
|
||||
const stop = startLotusDeafen();
|
||||
|
||||
send({ deafened: true });
|
||||
expect(emissions).toEqual([false]);
|
||||
|
||||
+17
-71
@@ -5,19 +5,13 @@ 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";
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { widget } from "../widget";
|
||||
import { LotusWidgetActions } from "./lotusActions";
|
||||
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||
|
||||
/**
|
||||
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
||||
@@ -41,22 +35,23 @@ import { LotusWidgetActions } from "./lotusActions";
|
||||
* 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.
|
||||
* tab/game audio while still hearing voices). It goes through the same
|
||||
* mechanism as deafen — a global behavior (`muteScreenshareAudio$`) that
|
||||
* `LivekitRoomAudioRenderer` turns into the `muted` prop of every
|
||||
* `Track.Source.ScreenShareAudio` element — rather than
|
||||
* `RemoteParticipant.setVolume(0, ScreenShareAudio)`: EC's own
|
||||
* `createVolumeControls` writes volume 1 through that very setter whenever a
|
||||
* new screenshare media item resolves, so a sharer who stopped and re-shared
|
||||
* (or a late joiner's share) came back at full volume while the host's button
|
||||
* still said "Unmute Screenshare Audio".
|
||||
*
|
||||
* 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).
|
||||
* State is closure-scoped (per invocation, matching the sibling lotus modules);
|
||||
* the screenshare-audio flag additionally lives in `setScreenshareAudioMuted$`
|
||||
* so the renderer can read it, and is reset on teardown.
|
||||
* 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
|
||||
@@ -64,7 +59,7 @@ import { LotusWidgetActions } from "./lotusActions";
|
||||
*
|
||||
* No effect unless the host sends the action. Returns a teardown function.
|
||||
*/
|
||||
export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
export function startLotusDeafen(): () => void {
|
||||
const w = widget;
|
||||
if (!w) return () => undefined;
|
||||
|
||||
@@ -107,53 +102,6 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// 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>();
|
||||
|
||||
const applyToParticipant = (p: RemoteParticipant): void => {
|
||||
if (screenshareAudioMuted) {
|
||||
p.setVolume(0, Track.Source.ScreenShareAudio);
|
||||
screenshareMuted.add(p);
|
||||
} else if (screenshareMuted.delete(p)) {
|
||||
p.setVolume(1, Track.Source.ScreenShareAudio);
|
||||
}
|
||||
};
|
||||
|
||||
const applyToRoom = (room: LivekitRoom): void =>
|
||||
room.remoteParticipants.forEach(applyToParticipant);
|
||||
|
||||
// Per-room ParticipantConnected listeners, so LATE JOINERS pick up the
|
||||
// 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).
|
||||
const roomListeners = new Map<LivekitRoom, () => void>();
|
||||
let rooms: LivekitRoom[] = [];
|
||||
|
||||
const sub = vm.allConnections$.subscribe((data) => {
|
||||
const next = data.getConnections().map((c) => c.livekitRoom);
|
||||
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 => {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
const data = ev.detail.data as
|
||||
@@ -168,20 +116,18 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
||||
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
||||
);
|
||||
applyGlobalMute();
|
||||
rooms.forEach(applyToRoom);
|
||||
if (setScreenshareAudioMuted$.value !== screenshareAudioMuted)
|
||||
setScreenshareAudioMuted$.next(screenshareAudioMuted);
|
||||
};
|
||||
|
||||
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
for (const off of roomListeners.values()) off();
|
||||
roomListeners.clear();
|
||||
// Leave the user's own output state as they had it before deafen.
|
||||
if (deafened) {
|
||||
deafened = false;
|
||||
applyGlobalMute();
|
||||
}
|
||||
screenshareMuted.clear();
|
||||
if (setScreenshareAudioMuted$.value) setScreenshareAudioMuted$.next(false);
|
||||
audioSub.unsubscribe();
|
||||
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
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 { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
/**
|
||||
* Whether the host has muted remote screenshare AUDIO (`io.lotus.set_deafen`
|
||||
* `screenshareAudioMuted`). Consumed by `LivekitRoomAudioRenderer` exactly the
|
||||
* way `muteAllAudio$` is: it becomes the `muted` prop of every
|
||||
* `Track.Source.ScreenShareAudio` element, so it survives re-renders, applies
|
||||
* to tracks that are published LATER (a sharer stopping and re-sharing, a late
|
||||
* joiner) and never fights EC's per-tile volume controls — which was the
|
||||
* failure mode of the old `RemoteParticipant.setVolume(0, ScreenShareAudio)`
|
||||
* approach: `createVolumeControls` writes its own volume (1) through the same
|
||||
* sink the moment a new screenshare media item appears, un-muting it.
|
||||
*/
|
||||
export const setScreenshareAudioMuted$ = new BehaviorSubject(false);
|
||||
|
||||
export const muteScreenshareAudio$ = globalScope.behavior(
|
||||
setScreenshareAudioMuted$,
|
||||
);
|
||||
@@ -73,6 +73,7 @@ import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { muteScreenshareAudio$ } from "../lotus/lotusScreenshareAudio";
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import {
|
||||
@@ -261,6 +262,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const { showControls, header: headerStyle } = useUrlParams();
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
// [lotus] host-driven "Mute Screenshare Audio" (io.lotus.set_deafen).
|
||||
const muteScreenshareAudio = useBehavior(muteScreenshareAudio$);
|
||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||
@@ -309,7 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// [lotus] Handle the host's io.lotus.set_deafen action to silence remote
|
||||
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
||||
// unless the host sends the action.
|
||||
useEffect(() => startLotusDeafen(vm), [vm]);
|
||||
useEffect(() => startLotusDeafen(), []);
|
||||
|
||||
const fatalCallError = useBehavior(vm.fatalError$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
@@ -657,6 +660,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
livekitRoom={livekitRoom}
|
||||
validIdentities={participants}
|
||||
muted={muteAllAudio}
|
||||
screenshareAudioMuted={muteScreenshareAudio}
|
||||
/>
|
||||
))}
|
||||
{renderContent()}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
SFUTokenRefusedError,
|
||||
} from "../../../utils/errors";
|
||||
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||
import { customLivekitUrl } from "../../../settings/settings";
|
||||
@@ -125,6 +126,43 @@ describe("LocalTransport", () => {
|
||||
expect(() => active$.value).toThrow(expectedError);
|
||||
});
|
||||
|
||||
it("[lotus] passes SFUTokenRefusedError through untouched", async () => {
|
||||
const scope = new ObservableScope();
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const refused = new SFUTokenRefusedError("This voice channel is full.");
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(() =>
|
||||
Promise.reject(refused),
|
||||
);
|
||||
const errors: Error[] = [];
|
||||
const { active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://example.org",
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
active$.subscribe(
|
||||
() => undefined,
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(errors).toStrictEqual([refused]);
|
||||
expect((errors[0] as SFUTokenRefusedError).localisedMessage).toBe(
|
||||
"This voice channel is full.",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits preferred transport after OpenID resolves", async () => {
|
||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||
mockConfig({
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
SFUTokenRefusedError,
|
||||
MatrixRTCTransportMissingError,
|
||||
NoMatrix2AuthorizationService,
|
||||
} from "../../../utils/errors.ts";
|
||||
@@ -261,7 +262,9 @@ async function doOpenIdAndJWTFromUrl(
|
||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
||||
if (
|
||||
e instanceof FailToGetOpenIdToken ||
|
||||
e instanceof NoMatrix2AuthorizationService
|
||||
e instanceof NoMatrix2AuthorizationService ||
|
||||
// [lotus] carries the token service's own refusal reason — keep it.
|
||||
e instanceof SFUTokenRefusedError
|
||||
) {
|
||||
// rethrow as is
|
||||
return e;
|
||||
|
||||
@@ -183,6 +183,13 @@ function generateRoomOption({
|
||||
audioCaptureDefaults: {
|
||||
...liveKitOptions.audioCaptureDefaults,
|
||||
deviceId: devices.audioInput.selected$.value?.id,
|
||||
// [lotus] Voice is mono. Without this, Firefox captures a 2-channel
|
||||
// audio interface (e.g. a Scarlett Solo with one XLR mic on input 1) as
|
||||
// stereo and, with browser audio processing off, publishes it that way —
|
||||
// peers hear the speaker in the left ear only. Chrome downmixes such
|
||||
// captures itself, which is why it only showed on Firefox. Screenshare
|
||||
// audio is captured separately and is unaffected.
|
||||
channelCount: 1,
|
||||
echoCancellation:
|
||||
echoCancellationSetting.getValue() &&
|
||||
getUrlParams().echoCancellation !== false,
|
||||
|
||||
@@ -23,6 +23,8 @@ export enum ErrorCode {
|
||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
|
||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||
/** [lotus] The SFU token service refused us with a human-readable reason (e.g. the voice-limit guard: channel full / no permission). */
|
||||
SFU_TOKEN_REFUSED = "SFU_TOKEN_REFUSED",
|
||||
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
||||
SFU_ERROR = "SFU_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
@@ -234,6 +236,24 @@ export class FailToStartLivekitConnection extends ElementCallError {
|
||||
/**
|
||||
* Error indicating that a LiveKit's server has hit its track limits.
|
||||
*/
|
||||
/**
|
||||
* [lotus] The SFU token service answered 403 with a reason we can show verbatim
|
||||
* — the voice-limit guard says things like "This voice channel is full." or
|
||||
* "You don't have permission to share your screen here." Without this the user
|
||||
* only ever saw "Something went wrong (OPEN_ID_ERROR)".
|
||||
*/
|
||||
export class SFUTokenRefusedError extends ElementCallError {
|
||||
public constructor(reason: string, cause?: Error) {
|
||||
super(
|
||||
t("error.sfu_token_refused"),
|
||||
ErrorCode.SFU_TOKEN_REFUSED,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
reason,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCapacityError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
|
||||
Reference in New Issue
Block a user