Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9723a21b5 | ||
|
|
9f472fd710 | ||
|
|
e7c27dd8a3 | ||
|
|
33b51e5dc6 | ||
|
|
07def55469 | ||
|
|
4a48ec98d0 | ||
|
|
b9d0ac7cac | ||
|
|
1e34923f6a | ||
|
|
fcb7f8456d | ||
|
|
c2267800be | ||
|
|
5421d545c4 | ||
|
|
2fafa3cf36 | ||
|
|
667230f6e3 | ||
|
|
746917a4c6 | ||
|
|
d881833491 | ||
|
|
021b1881e5 | ||
|
|
1b609d997b | ||
|
|
d9ac9a0fa4 | ||
|
|
66bfead7f1 | ||
|
|
ea579cb998 | ||
|
|
bc0e5ed432 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lotusguild/element-call-embedded",
|
"name": "@lotusguild/element-call-embedded",
|
||||||
"version": "0.25.0-lotus.2",
|
"version": "0.25.0-lotus.12",
|
||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE-AGPL-3.0",
|
"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.",
|
"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": "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.",
|
"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": "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.",
|
"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."
|
"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) => {
|
vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||||
return {
|
return {
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
AudioTrack: (props: {
|
||||||
|
trackRef: TrackReference;
|
||||||
|
muted?: boolean;
|
||||||
|
}): ReactNode => {
|
||||||
return (
|
return (
|
||||||
<audio data-testid={"audio"}>
|
<audio
|
||||||
|
data-testid={"audio"}
|
||||||
|
data-source={props.trackRef.publication.source}
|
||||||
|
data-muted={String(!!props.muted)}
|
||||||
|
>
|
||||||
{getTrackReferenceId(props.trackRef)}
|
{getTrackReferenceId(props.trackRef)}
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
@@ -83,6 +90,7 @@ function renderTestComponent(
|
|||||||
kind: Track.Kind;
|
kind: Track.Kind;
|
||||||
source: Track.Source;
|
source: Track.Source;
|
||||||
}[],
|
}[],
|
||||||
|
props: { muted?: boolean; screenshareAudioMuted?: boolean } = {},
|
||||||
): RenderResult {
|
): RenderResult {
|
||||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||||
mockRemoteParticipant({ identity }),
|
mockRemoteParticipant({ identity }),
|
||||||
@@ -117,6 +125,7 @@ function renderTestComponent(
|
|||||||
validIdentities={participants.map((p) => p.identity)}
|
validIdentities={participants.map((p) => p.identity)}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
url={""}
|
url={""}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</MediaDevicesProvider>,
|
</MediaDevicesProvider>,
|
||||||
);
|
);
|
||||||
@@ -286,3 +295,55 @@ it("should setup audioContext gain and pan", () => {
|
|||||||
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
||||||
expect(testAudioContext.pan.pan.value).toEqual(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.
|
* If set to `true`, the server will stop sending audio track data to the client.
|
||||||
*/
|
*/
|
||||||
muted?: boolean;
|
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,
|
livekitRoom,
|
||||||
validIdentities,
|
validIdentities,
|
||||||
muted,
|
muted,
|
||||||
|
screenshareAudioMuted,
|
||||||
}: MatrixAudioRendererProps): ReactNode {
|
}: MatrixAudioRendererProps): ReactNode {
|
||||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
@@ -143,7 +150,11 @@ export function LivekitRoomAudioRenderer({
|
|||||||
<AudioTrackWithAudioNodes
|
<AudioTrackWithAudioNodes
|
||||||
key={getTrackReferenceId(trackRef)}
|
key={getTrackReferenceId(trackRef)}
|
||||||
trackRef={trackRef}
|
trackRef={trackRef}
|
||||||
muted={muted}
|
muted={
|
||||||
|
muted ||
|
||||||
|
(screenshareAudioMuted &&
|
||||||
|
trackRef.publication.source === Track.Source.ScreenShareAudio)
|
||||||
|
}
|
||||||
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
||||||
audioNodes={audioNodes}
|
audioNodes={audioNodes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { MatrixError } from "matrix-js-sdk";
|
|||||||
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||||
import { testJWTToken } from "../utils/test-fixtures";
|
import { testJWTToken } from "../utils/test-fixtures";
|
||||||
import { ownMemberMock } from "../utils/test";
|
import { ownMemberMock } from "../utils/test";
|
||||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
import { FailToGetOpenIdToken, SFUTokenRefusedError } from "../utils/errors";
|
||||||
|
|
||||||
const sfuUrl = "https://sfu.example.org";
|
const sfuUrl = "https://sfu.example.org";
|
||||||
|
|
||||||
@@ -91,6 +91,31 @@ describe("getSFUConfigWithOpenID", () => {
|
|||||||
expect.fail("Expected test to throw;");
|
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 () => {
|
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import {
|
import {
|
||||||
type IOpenIDToken,
|
type IOpenIDToken,
|
||||||
type MatrixClient,
|
type MatrixClient,
|
||||||
|
MatrixError,
|
||||||
parseErrorResponse,
|
parseErrorResponse,
|
||||||
} from "matrix-js-sdk";
|
} from "matrix-js-sdk";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
@@ -16,6 +17,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import {
|
import {
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
NoMatrix2AuthorizationService,
|
NoMatrix2AuthorizationService,
|
||||||
|
SFUTokenRefusedError,
|
||||||
} from "../utils/errors";
|
} from "../utils/errors";
|
||||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
@@ -165,6 +167,14 @@ export async function getSFUConfigWithOpenID(
|
|||||||
logger?.info(`Got JWT from call's active focus URL.`);
|
logger?.info(`Got JWT from call's active focus URL.`);
|
||||||
return extractFullConfigFromToken(sfuConfig);
|
return extractFullConfigFromToken(sfuConfig);
|
||||||
} catch (ex) {
|
} 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(
|
throw new FailToGetOpenIdToken(
|
||||||
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
|
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe("LotusWidgetActions", () => {
|
|||||||
LotusWidgetActions.SetQuality,
|
LotusWidgetActions.SetQuality,
|
||||||
LotusWidgetActions.Decorations,
|
LotusWidgetActions.Decorations,
|
||||||
LotusWidgetActions.SetDeafen,
|
LotusWidgetActions.SetDeafen,
|
||||||
|
LotusWidgetActions.SetAudioOutput,
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ export enum LotusWidgetActions {
|
|||||||
* toggle can reflect reality rather than the requested state.
|
* toggle can reflect reality rather than the requested state.
|
||||||
*/
|
*/
|
||||||
DenoiseState = "io.lotus.denoise_state",
|
DenoiseState = "io.lotus.denoise_state",
|
||||||
|
/**
|
||||||
|
* fromWidget: one-shot end-of-call readout for the local participant —
|
||||||
|
* `{ durationMs, reconnects, poorMs, verdict }` (#143) — sent on SFU
|
||||||
|
* disconnect or in-call teardown, whichever comes first.
|
||||||
|
*/
|
||||||
|
CallSummary = "io.lotus.call_summary",
|
||||||
|
/** toWidget: select the audio output device `{ deviceId }` (#119). */
|
||||||
|
SetAudioOutput = "io.lotus.set_audio_output",
|
||||||
|
/** fromWidget: the currently selected output `{ deviceId }` (#119). */
|
||||||
|
AudioOutputState = "io.lotus.audio_output_state",
|
||||||
|
/**
|
||||||
|
* fromWidget: local screenshare reminder `{ kind: "ended" | "no-frames" | "alone" }`
|
||||||
|
* — window closed, no frames for 15 s, or 30 min of sharing with nobody
|
||||||
|
* else in the call (#39). Each fires at most once per share.
|
||||||
|
*/
|
||||||
|
ScreenshareNotice = "io.lotus.screenshare_notice",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
||||||
@@ -55,4 +71,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
|||||||
LotusWidgetActions.SetQuality,
|
LotusWidgetActions.SetQuality,
|
||||||
LotusWidgetActions.Decorations,
|
LotusWidgetActions.Decorations,
|
||||||
LotusWidgetActions.SetDeafen,
|
LotusWidgetActions.SetDeafen,
|
||||||
|
LotusWidgetActions.SetAudioOutput,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("[lotus] audio-capture URL param overrides", () => {
|
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)", () => {
|
test("with params defaulted to true, the Settings govern (upstream behaviour)", () => {
|
||||||
getUrlParams.mockReturnValue({
|
getUrlParams.mockReturnValue({
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
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 IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { lotusSendToHost } from "./lotusWidget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #119] Let the host switch the audio output device (headset ↔
|
||||||
|
* speakers) from its own call bar: `io.lotus.set_audio_output { deviceId }`
|
||||||
|
* selects the output; the fork answers each change (and the initial state)
|
||||||
|
* with `io.lotus.audio_output_state { deviceId, available: [{id,label}] }`.
|
||||||
|
* No effect unless the host sends the action. Returns a teardown function.
|
||||||
|
*/
|
||||||
|
export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
|
||||||
|
const w = widget;
|
||||||
|
if (!w) return () => undefined;
|
||||||
|
|
||||||
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
w.api.transport.reply(ev.detail, {});
|
||||||
|
const data = ev.detail.data as { deviceId?: string } | undefined;
|
||||||
|
if (typeof data?.deviceId !== "string") return;
|
||||||
|
logger.debug(`[lotus] set_audio_output: ${data.deviceId}`);
|
||||||
|
mediaDevices.audioOutput.select(data.deviceId);
|
||||||
|
};
|
||||||
|
w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler);
|
||||||
|
|
||||||
|
const sub = mediaDevices.audioOutput.selected$.subscribe((selected) => {
|
||||||
|
lotusSendToHost(LotusWidgetActions.AudioOutputState, {
|
||||||
|
deviceId: selected?.id ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,7 +53,11 @@ function mockMember(id: string, userId: string): Member {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockVm(members: Member[]): CallViewModel {
|
function mockVm(members: Member[]): CallViewModel {
|
||||||
return { userMedia$: of(members) } as unknown as CallViewModel;
|
return {
|
||||||
|
userMedia$: of(members),
|
||||||
|
// no livekit connections → the muted-speech tap never starts
|
||||||
|
allConnections$: of({ getConnections: () => [] }),
|
||||||
|
} as unknown as CallViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function participantsOf(call: number): unknown[] {
|
function participantsOf(call: number): unknown[] {
|
||||||
|
|||||||
+41
-28
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { combineLatest, of, type Subscription } from "rxjs";
|
import { combineLatest, of, startWith, type Subscription } from "rxjs";
|
||||||
import {
|
import {
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
map,
|
map,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
||||||
|
import { observeSpeakingWhileMuted$ } from "./lotusMutedSpeech";
|
||||||
|
|
||||||
interface ParticipantState {
|
interface ParticipantState {
|
||||||
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
|
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
|
||||||
@@ -25,6 +26,11 @@ interface ParticipantState {
|
|||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* [lotus #37] LOCAL participant only: voice detected on the mic while it is
|
||||||
|
* muted (see lotusMutedSpeech.ts). Absent for remote participants.
|
||||||
|
*/
|
||||||
|
speakingWhileMuted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +51,8 @@ function participantsEqual(
|
|||||||
p.userId === b[i].userId &&
|
p.userId === b[i].userId &&
|
||||||
p.speaking === b[i].speaking &&
|
p.speaking === b[i].speaking &&
|
||||||
p.audioEnabled === b[i].audioEnabled &&
|
p.audioEnabled === b[i].audioEnabled &&
|
||||||
p.videoEnabled === b[i].videoEnabled,
|
p.videoEnabled === b[i].videoEnabled &&
|
||||||
|
p.speakingWhileMuted === b[i].speakingWhileMuted,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,34 +73,40 @@ export function startLotusCallState(vm: CallViewModel): () => void {
|
|||||||
// lotusDecorations.ts.
|
// lotusDecorations.ts.
|
||||||
if (!widget) return () => undefined;
|
if (!widget) return () => undefined;
|
||||||
|
|
||||||
const sub: Subscription = vm.userMedia$
|
const participants$ = vm.userMedia$.pipe(
|
||||||
.pipe(
|
switchMap((members) =>
|
||||||
switchMap((members) =>
|
members.length === 0
|
||||||
members.length === 0
|
? of([] as (ParticipantState & { local: boolean })[])
|
||||||
? of([] as ParticipantState[])
|
: combineLatest(
|
||||||
: combineLatest(
|
members.map((m) =>
|
||||||
members.map((m) =>
|
combineLatest([
|
||||||
combineLatest([
|
m.speaking$,
|
||||||
m.speaking$,
|
m.audioEnabled$,
|
||||||
m.audioEnabled$,
|
m.videoEnabled$,
|
||||||
m.videoEnabled$,
|
]).pipe(
|
||||||
]).pipe(
|
map(([speaking, audioEnabled, videoEnabled]) => ({
|
||||||
map(
|
id: m.id,
|
||||||
([
|
userId: m.userId,
|
||||||
speaking,
|
speaking,
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
]): ParticipantState => ({
|
local: m.local === true,
|
||||||
id: m.id,
|
})),
|
||||||
userId: m.userId,
|
|
||||||
speaking,
|
|
||||||
audioEnabled,
|
|
||||||
videoEnabled,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sub: Subscription = combineLatest([
|
||||||
|
participants$,
|
||||||
|
observeSpeakingWhileMuted$(vm).pipe(startWith(false)),
|
||||||
|
])
|
||||||
|
.pipe(
|
||||||
|
map(([members, speakingWhileMuted]): ParticipantState[] =>
|
||||||
|
members.map(({ local, ...p }) =>
|
||||||
|
local ? { ...p, speakingWhileMuted } : p,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
|
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
|
||||||
// the throttle window isn't spent re-emitting an unchanged value.
|
// the throttle window isn't spent re-emitting an unchanged value.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
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 { ConnectionQuality } from "livekit-client";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CallQualityTracker } from "./lotusCallSummary";
|
||||||
|
|
||||||
|
const clock = (): { now: () => number; tick: (ms: number) => void } => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
return {
|
||||||
|
now: () => t,
|
||||||
|
tick: (ms) => {
|
||||||
|
t += ms;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CallQualityTracker", () => {
|
||||||
|
it("reports unknown when nothing was sampled", () => {
|
||||||
|
const c = clock();
|
||||||
|
const tr = new CallQualityTracker(c.now);
|
||||||
|
tr.start();
|
||||||
|
c.tick(60_000);
|
||||||
|
expect(tr.summary()).toEqual({
|
||||||
|
durationMs: 60_000,
|
||||||
|
reconnects: 0,
|
||||||
|
poorMs: 0,
|
||||||
|
verdict: "unknown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is good when quality stayed fine", () => {
|
||||||
|
const c = clock();
|
||||||
|
const tr = new CallQualityTracker(c.now);
|
||||||
|
tr.start();
|
||||||
|
tr.setQuality(ConnectionQuality.Excellent);
|
||||||
|
c.tick(30 * 60_000);
|
||||||
|
expect(tr.summary().verdict).toBe("good");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates poor time across episodes, including an open one", () => {
|
||||||
|
const c = clock();
|
||||||
|
const tr = new CallQualityTracker(c.now);
|
||||||
|
tr.start();
|
||||||
|
tr.setQuality(ConnectionQuality.Good);
|
||||||
|
c.tick(60_000);
|
||||||
|
tr.setQuality(ConnectionQuality.Poor);
|
||||||
|
c.tick(10_000);
|
||||||
|
tr.setQuality(ConnectionQuality.Good);
|
||||||
|
c.tick(60_000);
|
||||||
|
tr.setQuality(ConnectionQuality.Lost);
|
||||||
|
c.tick(5_000);
|
||||||
|
const s = tr.summary();
|
||||||
|
expect(s.poorMs).toBe(15_000);
|
||||||
|
expect(s.durationMs).toBe(135_000);
|
||||||
|
expect(s.verdict).toBe("fair");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is poor with many reconnects or mostly-poor quality", () => {
|
||||||
|
const c = clock();
|
||||||
|
const tr = new CallQualityTracker(c.now);
|
||||||
|
tr.start();
|
||||||
|
tr.setQuality(ConnectionQuality.Good);
|
||||||
|
for (let i = 0; i < 4; i += 1) tr.reconnect();
|
||||||
|
c.tick(60_000);
|
||||||
|
expect(tr.summary()).toMatchObject({ reconnects: 4, verdict: "poor" });
|
||||||
|
|
||||||
|
const tr2 = new CallQualityTracker(c.now);
|
||||||
|
tr2.start();
|
||||||
|
tr2.setQuality(ConnectionQuality.Poor);
|
||||||
|
c.tick(60_000);
|
||||||
|
expect(tr2.summary().verdict).toBe("poor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown samples and only starts once", () => {
|
||||||
|
const c = clock();
|
||||||
|
const tr = new CallQualityTracker(c.now);
|
||||||
|
tr.start();
|
||||||
|
c.tick(1_000);
|
||||||
|
tr.start();
|
||||||
|
tr.setQuality(ConnectionQuality.Unknown);
|
||||||
|
c.tick(1_000);
|
||||||
|
expect(tr.summary()).toMatchObject({
|
||||||
|
durationMs: 2_000,
|
||||||
|
verdict: "unknown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
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 {
|
||||||
|
ConnectionQuality,
|
||||||
|
type Participant,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { lotusSendToHost } from "./lotusWidget";
|
||||||
|
|
||||||
|
export type CallQualityVerdict = "good" | "fair" | "poor" | "unknown";
|
||||||
|
|
||||||
|
export interface CallSummary {
|
||||||
|
/** Wall-clock time connected to the SFU, ms. */
|
||||||
|
durationMs: number;
|
||||||
|
/** LiveKit reconnect attempts during the call. */
|
||||||
|
reconnects: number;
|
||||||
|
/** Time the local connection quality was reported poor/lost, ms. */
|
||||||
|
poorMs: number;
|
||||||
|
verdict: CallQualityVerdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #143] Per-call connection quality, kept in memory for the local
|
||||||
|
* participant only and summarised once at hangup. Nothing is stored or sent
|
||||||
|
* anywhere but to the host at the end.
|
||||||
|
*/
|
||||||
|
export class CallQualityTracker {
|
||||||
|
private startedAt: number | undefined;
|
||||||
|
|
||||||
|
private poorSince: number | undefined;
|
||||||
|
|
||||||
|
private poorMs = 0;
|
||||||
|
|
||||||
|
private reconnects = 0;
|
||||||
|
|
||||||
|
private sampled = false;
|
||||||
|
|
||||||
|
public constructor(private readonly now: () => number = () => Date.now()) {}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
if (this.startedAt === undefined) this.startedAt = this.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setQuality(quality: ConnectionQuality): void {
|
||||||
|
if (quality === ConnectionQuality.Unknown) return;
|
||||||
|
this.sampled = true;
|
||||||
|
const bad =
|
||||||
|
quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost;
|
||||||
|
if (bad && this.poorSince === undefined) this.poorSince = this.now();
|
||||||
|
if (!bad && this.poorSince !== undefined) {
|
||||||
|
this.poorMs += this.now() - this.poorSince;
|
||||||
|
this.poorSince = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconnect(): void {
|
||||||
|
this.reconnects += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public summary(): CallSummary {
|
||||||
|
const end = this.now();
|
||||||
|
const durationMs =
|
||||||
|
this.startedAt === undefined ? 0 : Math.max(0, end - this.startedAt);
|
||||||
|
const poorMs =
|
||||||
|
this.poorMs + (this.poorSince === undefined ? 0 : end - this.poorSince);
|
||||||
|
let verdict: CallQualityVerdict = "unknown";
|
||||||
|
if (this.sampled && durationMs > 0) {
|
||||||
|
const poorShare = poorMs / durationMs;
|
||||||
|
if (poorShare < 0.05 && this.reconnects <= 1) verdict = "good";
|
||||||
|
else if (poorShare < 0.25 && this.reconnects <= 3) verdict = "fair";
|
||||||
|
else verdict = "poor";
|
||||||
|
}
|
||||||
|
return { durationMs, reconnects: this.reconnects, poorMs, verdict };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track the local participant's LiveKit connection quality and reconnects
|
||||||
|
* for the life of the in-call view, and send `io.lotus.call_summary` to the
|
||||||
|
* host once — on the first SFU disconnect or on teardown, whichever comes
|
||||||
|
* first — so the host can show "41 min · connection was good" at hangup.
|
||||||
|
*/
|
||||||
|
export function startLotusCallSummary(vm: CallViewModel): () => void {
|
||||||
|
if (!widget) return () => undefined;
|
||||||
|
const tracker = new CallQualityTracker();
|
||||||
|
let sent = false;
|
||||||
|
const send = (): void => {
|
||||||
|
if (sent) return;
|
||||||
|
sent = true;
|
||||||
|
lotusSendToHost(LotusWidgetActions.CallSummary, tracker.summary());
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = new Map<LivekitRoom, () => void>();
|
||||||
|
const attach = (room: LivekitRoom): void => {
|
||||||
|
const onQuality = (
|
||||||
|
quality: ConnectionQuality,
|
||||||
|
participant: Participant,
|
||||||
|
): void => {
|
||||||
|
if (participant.isLocal) tracker.setQuality(quality);
|
||||||
|
};
|
||||||
|
const onConnected = (): void => tracker.start();
|
||||||
|
const onReconnecting = (): void => tracker.reconnect();
|
||||||
|
const onDisconnected = (): void => send();
|
||||||
|
room.on(RoomEvent.ConnectionQualityChanged, onQuality);
|
||||||
|
room.on(RoomEvent.Connected, onConnected);
|
||||||
|
room.on(RoomEvent.Reconnecting, onReconnecting);
|
||||||
|
room.on(RoomEvent.Disconnected, onDisconnected);
|
||||||
|
if (room.state === "connected") tracker.start();
|
||||||
|
listeners.set(room, () => {
|
||||||
|
room.off(RoomEvent.ConnectionQualityChanged, onQuality);
|
||||||
|
room.off(RoomEvent.Connected, onConnected);
|
||||||
|
room.off(RoomEvent.Reconnecting, onReconnecting);
|
||||||
|
room.off(RoomEvent.Disconnected, onDisconnected);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sub = vm.allConnections$.subscribe((data) => {
|
||||||
|
const next = data.getConnections().map((c) => c.livekitRoom);
|
||||||
|
for (const [room, off] of listeners) {
|
||||||
|
if (!next.includes(room)) {
|
||||||
|
off();
|
||||||
|
listeners.delete(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const room of next) if (!listeners.has(room)) attach(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
for (const off of listeners.values()) off();
|
||||||
|
listeners.clear();
|
||||||
|
send();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,13 +7,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
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 { setAudioEnabled$ } from "../controls";
|
||||||
import { startLotusDeafen } from "./lotusDeafen";
|
import { startLotusDeafen } from "./lotusDeafen";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||||
|
|
||||||
const lazyActions = new EventEmitter();
|
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 {
|
function send(data: unknown): void {
|
||||||
lazyActions.emit(LotusWidgetActions.SetDeafen, {
|
lazyActions.emit(LotusWidgetActions.SetDeafen, {
|
||||||
detail: { data },
|
detail: { data },
|
||||||
@@ -60,7 +46,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
test("deafen mutes, and undeafen unmutes, EC's global audio output", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
expect(emissions).toEqual([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)", () => {
|
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 });
|
||||||
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", () => {
|
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.
|
// The user mutes all audio through EC's own control first.
|
||||||
setAudioEnabled$.next(false);
|
setAudioEnabled$.next(false);
|
||||||
@@ -101,48 +87,44 @@ test("undeafen does not re-enable audio the user had muted themselves", () => {
|
|||||||
expect(emissions).toEqual([false]);
|
expect(emissions).toEqual([false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deafen never touches per-participant volume", () => {
|
test("deafen never touches the screenshare-audio flag", () => {
|
||||||
const participant = { setVolume: vi.fn() };
|
const stop = startLotusDeafen();
|
||||||
const stop = startLotusDeafen(mockVm([participant]));
|
|
||||||
|
|
||||||
send({ deafened: true, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: false });
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
send({ deafened: false, screenshareAudioMuted: false });
|
||||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||||
|
|
||||||
stop();
|
stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("screenshare-audio mute is applied per source and only undone for participants we muted", () => {
|
test("screenshare-audio mute is a global flag the renderer reads, reset on teardown", () => {
|
||||||
const participant = { setVolume: vi.fn() };
|
const seen: boolean[] = [];
|
||||||
const stop = startLotusDeafen(mockVm([participant]));
|
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 });
|
send({ deafened: false, screenshareAudioMuted: false });
|
||||||
expect(participant.setVolume).not.toHaveBeenCalled();
|
expect(seen).toEqual([false]);
|
||||||
|
|
||||||
send({ deafened: false, screenshareAudioMuted: true });
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||||
0,
|
// Re-applying the same state (host resend after reconnect) is a no-op.
|
||||||
Track.Source.ScreenShareAudio,
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
);
|
expect(seen).toEqual([false, true]);
|
||||||
|
|
||||||
participant.setVolume.mockClear();
|
// Deafen + undeafen while screenshare audio is muted leaves it muted.
|
||||||
send({ deafened: false, screenshareAudioMuted: false });
|
send({ deafened: true, screenshareAudioMuted: true });
|
||||||
expect(participant.setVolume).toHaveBeenCalledWith(
|
send({ deafened: false, screenshareAudioMuted: true });
|
||||||
1,
|
expect(setScreenshareAudioMuted$.value).toBe(true);
|
||||||
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();
|
|
||||||
|
|
||||||
|
// Leaving the call clears it so the next call starts clean.
|
||||||
stop();
|
stop();
|
||||||
|
expect(setScreenshareAudioMuted$.value).toBe(false);
|
||||||
|
flagSub.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a partial payload only moves the flag it names", () => {
|
test("a partial payload only moves the flag it names", () => {
|
||||||
const stop = startLotusDeafen(mockVm());
|
const stop = startLotusDeafen();
|
||||||
|
|
||||||
send({ deafened: true });
|
send({ deafened: true });
|
||||||
expect(emissions).toEqual([false]);
|
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.
|
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 { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
||||||
import { setAudioEnabled$ } from "../controls";
|
import { setAudioEnabled$ } from "../controls";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { setScreenshareAudioMuted$ } from "./lotusScreenshareAudio";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
* Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's
|
||||||
@@ -41,22 +35,23 @@ import { LotusWidgetActions } from "./lotusActions";
|
|||||||
* volume/mute state.
|
* volume/mute state.
|
||||||
*
|
*
|
||||||
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
* `screenshareAudioMuted` is a NARROWER, independent host control (drop shared
|
||||||
* tab/game audio while still hearing voices). EC has no global per-source mute,
|
* tab/game audio while still hearing voices). It goes through the same
|
||||||
* so that one still has to go through
|
* mechanism as deafen — a global behavior (`muteScreenshareAudio$`) that
|
||||||
* `RemoteParticipant.setVolume(volume, Track.Source.ScreenShareAudio)` — whose
|
* `LivekitRoomAudioRenderer` turns into the `muted` prop of every
|
||||||
* verified signature in livekit-client ^2.18.1 is
|
* `Track.Source.ScreenShareAudio` element — rather than
|
||||||
* `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`.
|
* `RemoteParticipant.setVolume(0, ScreenShareAudio)`: EC's own
|
||||||
* It is re-applied to late joiners via `RoomEvent.ParticipantConnected`, and we
|
* `createVolumeControls` writes volume 1 through that very setter whenever a
|
||||||
* only ever restore participants we muted ourselves. Known limitation: EC's own
|
* new screenshare media item resolves, so a sharer who stopped and re-shared
|
||||||
* screenshare volume slider writes the same `volumeMap`, so a user who moves
|
* (or a late joiner's share) came back at full volume while the host's button
|
||||||
* that slider while screenshare audio is host-muted wins; the mic path (the
|
* still said "Unmute Screenshare Audio".
|
||||||
* 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
|
* 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
|
* deafen (and never touches the `mute-all-audio` setting), so a user who had
|
||||||
* already muted all audio themselves stays muted.
|
* 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
|
* Applying the same state twice is a no-op, so the host's
|
||||||
* `CallControl.resendForkState()` after a reconnect is safe. The host re-sends
|
* `CallControl.resendForkState()` after a reconnect is safe. The host re-sends
|
||||||
* the current state on every call join (CallControl.forceState), so a fresh
|
* 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.
|
* 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;
|
const w = widget;
|
||||||
if (!w) return () => undefined;
|
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 => {
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
w.api.transport.reply(ev.detail, {});
|
w.api.transport.reply(ev.detail, {});
|
||||||
const data = ev.detail.data as
|
const data = ev.detail.data as
|
||||||
@@ -168,20 +116,18 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
|
|||||||
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
`[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`,
|
||||||
);
|
);
|
||||||
applyGlobalMute();
|
applyGlobalMute();
|
||||||
rooms.forEach(applyToRoom);
|
if (setScreenshareAudioMuted$.value !== screenshareAudioMuted)
|
||||||
|
setScreenshareAudioMuted$.next(screenshareAudioMuted);
|
||||||
};
|
};
|
||||||
|
|
||||||
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
w.lazyActions.on(LotusWidgetActions.SetDeafen, handler);
|
||||||
return () => {
|
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.
|
// Leave the user's own output state as they had it before deafen.
|
||||||
if (deafened) {
|
if (deafened) {
|
||||||
deafened = false;
|
deafened = false;
|
||||||
applyGlobalMute();
|
applyGlobalMute();
|
||||||
}
|
}
|
||||||
screenshareMuted.clear();
|
if (setScreenshareAudioMuted$.value) setScreenshareAudioMuted$.next(false);
|
||||||
audioSub.unsubscribe();
|
audioSub.unsubscribe();
|
||||||
w.lazyActions.off(LotusWidgetActions.SetDeafen, handler);
|
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 { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { MutedSpeechGate } from "./lotusMutedSpeech";
|
||||||
|
|
||||||
|
test("needs 300 ms of voice to switch on and 800 ms of quiet to switch off", () => {
|
||||||
|
const g = new MutedSpeechGate(0.015);
|
||||||
|
expect(g.push(0.1)).toBe(false);
|
||||||
|
expect(g.push(0.1)).toBe(false);
|
||||||
|
expect(g.push(0.1)).toBe(true);
|
||||||
|
// a short dip does not drop it
|
||||||
|
for (let i = 0; i < 7; i++) expect(g.push(0.0)).toBe(true);
|
||||||
|
expect(g.push(0.0)).toBe(false);
|
||||||
|
// one loud sample after quiet does not re-trigger
|
||||||
|
expect(g.push(0.2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard-level noise below the threshold never triggers", () => {
|
||||||
|
const g = new MutedSpeechGate(0.015);
|
||||||
|
for (let i = 0; i < 50; i++) expect(g.push(0.01)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
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 LocalTrackPublication,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { Observable, distinctUntilChanged, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #37] "Talking while muted" detection for the LOCAL participant.
|
||||||
|
*
|
||||||
|
* While the mic is muted LiveKit keeps the capture alive and merely disables
|
||||||
|
* the published MediaStreamTrack, so VAD/`speaking` is false and nothing
|
||||||
|
* tells the user they are talking into a muted mic. This taps a CLONE of the
|
||||||
|
* published track (the post-processor track when the in-source denoiser is
|
||||||
|
* active, so keyboard noise doesn't count), samples RMS at ~10 Hz and emits a
|
||||||
|
* debounced boolean. Zero cost when unmuted (tap torn down), local-only —
|
||||||
|
* the flag rides `io.lotus.call_state` to the host and never reaches other
|
||||||
|
* participants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MUTED_SPEECH_RMS = 0.015; // ≈ −36 dBFS; normal speech into a headset is 0.05–0.3
|
||||||
|
const SAMPLE_MS = 100;
|
||||||
|
const ON_SAMPLES = 3; // 300 ms of voice before we say "talking"
|
||||||
|
const OFF_SAMPLES = 8; // 800 ms of quiet before we drop it
|
||||||
|
|
||||||
|
/** Pure hysteresis gate over successive RMS samples (unit-tested). */
|
||||||
|
export class MutedSpeechGate {
|
||||||
|
private above = 0;
|
||||||
|
|
||||||
|
private below = 0;
|
||||||
|
|
||||||
|
private on = false;
|
||||||
|
|
||||||
|
public constructor(private readonly threshold = MUTED_SPEECH_RMS) {}
|
||||||
|
|
||||||
|
public push(rms: number): boolean {
|
||||||
|
if (rms >= this.threshold) {
|
||||||
|
this.above += 1;
|
||||||
|
this.below = 0;
|
||||||
|
if (!this.on && this.above >= ON_SAMPLES) this.on = true;
|
||||||
|
} else {
|
||||||
|
this.below += 1;
|
||||||
|
this.above = 0;
|
||||||
|
if (this.on && this.below >= OFF_SAMPLES) this.on = false;
|
||||||
|
}
|
||||||
|
return this.on;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get value(): boolean {
|
||||||
|
return this.on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutedMicTrack = (room: LivekitRoom): MediaStreamTrack | null => {
|
||||||
|
const pub: LocalTrackPublication | undefined =
|
||||||
|
room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||||
|
const track = pub?.track?.mediaStreamTrack;
|
||||||
|
return pub?.isMuted && track && track.readyState === "live" ? track : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits while the local mic is muted and voice is detected on it. Emits
|
||||||
|
* `false` whenever the mic is unmuted, unpublished or the connection changes.
|
||||||
|
*/
|
||||||
|
export function observeSpeakingWhileMuted$(
|
||||||
|
vm: CallViewModel,
|
||||||
|
): Observable<boolean> {
|
||||||
|
return vm.allConnections$.pipe(
|
||||||
|
switchMap(
|
||||||
|
(data) =>
|
||||||
|
new Observable<boolean>((subscriber) => {
|
||||||
|
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
let clone: MediaStreamTrack | null = null;
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let tapped: MediaStreamTrack | null = null;
|
||||||
|
|
||||||
|
const stopTap = (): void => {
|
||||||
|
if (timer !== undefined) clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
clone?.stop();
|
||||||
|
clone = null;
|
||||||
|
void ctx?.close().catch(() => undefined);
|
||||||
|
ctx = null;
|
||||||
|
tapped = null;
|
||||||
|
subscriber.next(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTap = (source: MediaStreamTrack): void => {
|
||||||
|
try {
|
||||||
|
clone = source.clone();
|
||||||
|
clone.enabled = true; // the source is disabled by the mute — the clone must not be
|
||||||
|
ctx = new AudioContext();
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 1024;
|
||||||
|
ctx
|
||||||
|
.createMediaStreamSource(new MediaStream([clone]))
|
||||||
|
.connect(analyser);
|
||||||
|
const buf = new Float32Array(analyser.fftSize);
|
||||||
|
const gate = new MutedSpeechGate();
|
||||||
|
tapped = source;
|
||||||
|
timer = setInterval(() => {
|
||||||
|
analyser.getFloatTimeDomainData(buf);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
|
||||||
|
subscriber.next(gate.push(Math.sqrt(sum / buf.length)));
|
||||||
|
}, SAMPLE_MS);
|
||||||
|
} catch {
|
||||||
|
stopTap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcile = (): void => {
|
||||||
|
const track =
|
||||||
|
rooms.map(mutedMicTrack).find((t) => t !== null) ?? null;
|
||||||
|
if (track === tapped) return;
|
||||||
|
if (tapped) stopTap();
|
||||||
|
if (track) startTap(track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
RoomEvent.TrackMuted,
|
||||||
|
RoomEvent.TrackUnmuted,
|
||||||
|
RoomEvent.LocalTrackPublished,
|
||||||
|
RoomEvent.LocalTrackUnpublished,
|
||||||
|
RoomEvent.Reconnected,
|
||||||
|
RoomEvent.Disconnected,
|
||||||
|
] as const;
|
||||||
|
rooms.forEach((room) =>
|
||||||
|
events.forEach((ev) => room.on(ev, reconcile)),
|
||||||
|
);
|
||||||
|
subscriber.next(false);
|
||||||
|
reconcile();
|
||||||
|
return () => {
|
||||||
|
rooms.forEach((room) =>
|
||||||
|
events.forEach((ev) => room.off(ev, reconcile)),
|
||||||
|
);
|
||||||
|
if (tapped) stopTap();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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$,
|
||||||
|
);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
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 { RoomEvent, Track } from "livekit-client";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import {
|
||||||
|
ALONE_MS,
|
||||||
|
NO_FRAMES_MS,
|
||||||
|
startLotusScreenshareWatch,
|
||||||
|
} from "./lotusScreenshareWatch";
|
||||||
|
|
||||||
|
const sent: unknown[] = [];
|
||||||
|
vi.mock("../widget", () => ({ widget: { api: {} } }));
|
||||||
|
vi.mock("./lotusWidget", () => ({
|
||||||
|
lotusSendToHost: (action: string, data: unknown): boolean => {
|
||||||
|
sent.push({ action, data });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeTrack extends EventTarget {}
|
||||||
|
|
||||||
|
const makeRoom = (): {
|
||||||
|
room: Record<string, unknown>;
|
||||||
|
emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
remote: Map<string, unknown>;
|
||||||
|
} => {
|
||||||
|
const handlers = new Map<string, ((...args: unknown[]) => void)[]>();
|
||||||
|
const remote = new Map<string, unknown>();
|
||||||
|
const room = {
|
||||||
|
remoteParticipants: remote,
|
||||||
|
localParticipant: { trackPublications: new Map() },
|
||||||
|
on: (event: string, h: (...args: unknown[]) => void): void => {
|
||||||
|
handlers.set(event, [...(handlers.get(event) ?? []), h]);
|
||||||
|
},
|
||||||
|
off: (event: string, h: (...args: unknown[]) => void): void => {
|
||||||
|
handlers.set(
|
||||||
|
event,
|
||||||
|
(handlers.get(event) ?? []).filter((x) => x !== h),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
remote,
|
||||||
|
emit: (event, ...args): void => {
|
||||||
|
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("startLotusScreenshareWatch", () => {
|
||||||
|
let clock = 0;
|
||||||
|
beforeEach(() => {
|
||||||
|
sent.length = 0;
|
||||||
|
clock = 1_000_000;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
const start = (): ReturnType<typeof makeRoom> & { stop: () => void } => {
|
||||||
|
const fake = makeRoom();
|
||||||
|
const connections = new BehaviorSubject({
|
||||||
|
getConnections: () => [{ livekitRoom: fake.room }],
|
||||||
|
});
|
||||||
|
const stop = startLotusScreenshareWatch(
|
||||||
|
{ allConnections$: connections } as unknown as CallViewModel,
|
||||||
|
() => clock,
|
||||||
|
);
|
||||||
|
return { ...fake, stop };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports a share whose track ended", () => {
|
||||||
|
const { emit, stop } = start();
|
||||||
|
const mst = new FakeTrack();
|
||||||
|
emit(RoomEvent.LocalTrackPublished, {
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
track: { mediaStreamTrack: mst },
|
||||||
|
});
|
||||||
|
mst.dispatchEvent(new Event("ended"));
|
||||||
|
expect(sent).toEqual([
|
||||||
|
{ action: "io.lotus.screenshare_notice", data: { kind: "ended" } },
|
||||||
|
]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no frames after a sustained mute, once, and not after an unmute", () => {
|
||||||
|
const { emit, stop } = start();
|
||||||
|
const mst = new FakeTrack();
|
||||||
|
emit(RoomEvent.LocalTrackPublished, {
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
track: { mediaStreamTrack: mst },
|
||||||
|
});
|
||||||
|
mst.dispatchEvent(new Event("mute"));
|
||||||
|
vi.advanceTimersByTime(NO_FRAMES_MS / 2);
|
||||||
|
mst.dispatchEvent(new Event("unmute"));
|
||||||
|
vi.advanceTimersByTime(NO_FRAMES_MS);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
mst.dispatchEvent(new Event("mute"));
|
||||||
|
vi.advanceTimersByTime(NO_FRAMES_MS);
|
||||||
|
mst.dispatchEvent(new Event("mute"));
|
||||||
|
vi.advanceTimersByTime(NO_FRAMES_MS);
|
||||||
|
expect(sent).toEqual([
|
||||||
|
{ action: "io.lotus.screenshare_notice", data: { kind: "no-frames" } },
|
||||||
|
]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nudges after 30 min of sharing with nobody else, never while others are present", () => {
|
||||||
|
const { emit, remote, stop } = start();
|
||||||
|
emit(RoomEvent.LocalTrackPublished, {
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
track: { mediaStreamTrack: new FakeTrack() },
|
||||||
|
});
|
||||||
|
remote.set("bob", {});
|
||||||
|
clock += ALONE_MS + 60_000;
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
remote.clear();
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(sent).toEqual([
|
||||||
|
{ action: "io.lotus.screenshare_notice", data: { kind: "alone" } },
|
||||||
|
]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ended from the unpublish when LiveKit's handler ran first", () => {
|
||||||
|
const { emit, stop } = start();
|
||||||
|
const share = new FakeTrack() as FakeTrack & { readyState: string };
|
||||||
|
share.readyState = "ended";
|
||||||
|
const pub = {
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
track: { mediaStreamTrack: share },
|
||||||
|
};
|
||||||
|
emit(RoomEvent.LocalTrackPublished, pub);
|
||||||
|
emit(RoomEvent.LocalTrackUnpublished, pub);
|
||||||
|
share.dispatchEvent(new Event("ended"));
|
||||||
|
expect(sent).toEqual([
|
||||||
|
{ action: "io.lotus.screenshare_notice", data: { kind: "ended" } },
|
||||||
|
]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-screenshare publications and stops watching on unpublish", () => {
|
||||||
|
const { emit, stop } = start();
|
||||||
|
const cam = new FakeTrack();
|
||||||
|
emit(RoomEvent.LocalTrackPublished, {
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
track: { mediaStreamTrack: cam },
|
||||||
|
});
|
||||||
|
cam.dispatchEvent(new Event("ended"));
|
||||||
|
const share = new FakeTrack();
|
||||||
|
const pub = {
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
track: { mediaStreamTrack: share },
|
||||||
|
};
|
||||||
|
emit(RoomEvent.LocalTrackPublished, pub);
|
||||||
|
emit(RoomEvent.LocalTrackUnpublished, pub);
|
||||||
|
share.dispatchEvent(new Event("ended"));
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
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 LocalTrackPublication,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { lotusSendToHost } from "./lotusWidget";
|
||||||
|
|
||||||
|
export type ScreenshareNoticeKind = "ended" | "no-frames" | "alone";
|
||||||
|
|
||||||
|
/** A shared window minimised/occluded this long with no frames → tell the host. */
|
||||||
|
export const NO_FRAMES_MS = 15_000;
|
||||||
|
/** Sharing this long with nobody else in the call → one "still sharing?" nudge. */
|
||||||
|
export const ALONE_MS = 30 * 60_000;
|
||||||
|
const ALONE_CHECK_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #39] Watch the local screenshare and tell the host about the two
|
||||||
|
* cases people miss: the share went black/ended (window closed or minimised)
|
||||||
|
* and a long share with nobody else in the call. Detection only — the host
|
||||||
|
* renders the notices. Each notice fires at most once per share.
|
||||||
|
*/
|
||||||
|
export function startLotusScreenshareWatch(
|
||||||
|
vm: CallViewModel,
|
||||||
|
now: () => number = () => Date.now(),
|
||||||
|
): () => void {
|
||||||
|
if (!widget) return () => undefined;
|
||||||
|
|
||||||
|
const notify = (kind: ScreenshareNoticeKind): void => {
|
||||||
|
lotusSendToHost(LotusWidgetActions.ScreenshareNotice, { kind });
|
||||||
|
};
|
||||||
|
|
||||||
|
const perRoom = new Map<LivekitRoom, () => void>();
|
||||||
|
|
||||||
|
const attach = (room: LivekitRoom): void => {
|
||||||
|
let cleanupTrack: (() => void) | undefined;
|
||||||
|
let endedHook: (() => void) | undefined;
|
||||||
|
|
||||||
|
const watchPublication = (pub: LocalTrackPublication): void => {
|
||||||
|
if (pub.source !== Track.Source.ScreenShare) return;
|
||||||
|
cleanupTrack?.();
|
||||||
|
const mst = pub.track?.mediaStreamTrack;
|
||||||
|
const startedAt = now();
|
||||||
|
let noFramesTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let sentNoFrames = false;
|
||||||
|
let sentAlone = false;
|
||||||
|
|
||||||
|
let sentEnded = false;
|
||||||
|
const onEnded = (): void => {
|
||||||
|
if (sentEnded) return;
|
||||||
|
sentEnded = true;
|
||||||
|
notify("ended");
|
||||||
|
};
|
||||||
|
const onMute = (): void => {
|
||||||
|
if (sentNoFrames) return;
|
||||||
|
noFramesTimer = setTimeout(() => {
|
||||||
|
sentNoFrames = true;
|
||||||
|
notify("no-frames");
|
||||||
|
}, NO_FRAMES_MS);
|
||||||
|
};
|
||||||
|
const onUnmute = (): void => {
|
||||||
|
if (noFramesTimer !== undefined) clearTimeout(noFramesTimer);
|
||||||
|
noFramesTimer = undefined;
|
||||||
|
};
|
||||||
|
endedHook = onEnded;
|
||||||
|
mst?.addEventListener("ended", onEnded);
|
||||||
|
mst?.addEventListener("mute", onMute);
|
||||||
|
mst?.addEventListener("unmute", onUnmute);
|
||||||
|
|
||||||
|
const aloneTimer = setInterval(() => {
|
||||||
|
if (sentAlone) return;
|
||||||
|
if (room.remoteParticipants.size > 0) return;
|
||||||
|
if (now() - startedAt < ALONE_MS) return;
|
||||||
|
sentAlone = true;
|
||||||
|
notify("alone");
|
||||||
|
}, ALONE_CHECK_MS);
|
||||||
|
|
||||||
|
cleanupTrack = (): void => {
|
||||||
|
mst?.removeEventListener("ended", onEnded);
|
||||||
|
mst?.removeEventListener("mute", onMute);
|
||||||
|
mst?.removeEventListener("unmute", onUnmute);
|
||||||
|
if (noFramesTimer !== undefined) clearTimeout(noFramesTimer);
|
||||||
|
clearInterval(aloneTimer);
|
||||||
|
cleanupTrack = undefined;
|
||||||
|
endedHook = undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// LiveKit's own `ended` handler runs first and unpublishes the share; a
|
||||||
|
// listener removed during that dispatch never fires, so decide from the
|
||||||
|
// track's state here as well.
|
||||||
|
const onUnpublished = (pub: LocalTrackPublication): void => {
|
||||||
|
if (pub.source !== Track.Source.ScreenShare) return;
|
||||||
|
if (pub.track?.mediaStreamTrack?.readyState === "ended") endedHook?.();
|
||||||
|
cleanupTrack?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, watchPublication);
|
||||||
|
room.on(RoomEvent.LocalTrackUnpublished, onUnpublished);
|
||||||
|
room.localParticipant.trackPublications.forEach(watchPublication);
|
||||||
|
|
||||||
|
perRoom.set(room, () => {
|
||||||
|
cleanupTrack?.();
|
||||||
|
room.off(RoomEvent.LocalTrackPublished, watchPublication);
|
||||||
|
room.off(RoomEvent.LocalTrackUnpublished, onUnpublished);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sub = vm.allConnections$.subscribe((data) => {
|
||||||
|
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
||||||
|
for (const [room, off] of perRoom) {
|
||||||
|
if (!rooms.includes(room)) {
|
||||||
|
off();
|
||||||
|
perRoom.delete(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const room of rooms) if (!perRoom.has(room)) attach(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
for (const off of perRoom.values()) off();
|
||||||
|
perRoom.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
+17
-1
@@ -35,6 +35,9 @@ import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
|||||||
import { startLotusQuality } from "../lotus/lotusQuality";
|
import { startLotusQuality } from "../lotus/lotusQuality";
|
||||||
import { startLotusDecorations } from "../lotus/lotusDecorations";
|
import { startLotusDecorations } from "../lotus/lotusDecorations";
|
||||||
import { startLotusDenoise } from "../lotus/lotusDenoise";
|
import { startLotusDenoise } from "../lotus/lotusDenoise";
|
||||||
|
import { startLotusCallSummary } from "../lotus/lotusCallSummary";
|
||||||
|
import { startLotusAudioOutput } from "../lotus/lotusAudioOutput";
|
||||||
|
import { startLotusScreenshareWatch } from "../lotus/lotusScreenshareWatch";
|
||||||
import { startLotusDeafen } from "../lotus/lotusDeafen";
|
import { startLotusDeafen } from "../lotus/lotusDeafen";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
@@ -73,6 +76,7 @@ import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
|
|||||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
|
import { muteScreenshareAudio$ } from "../lotus/lotusScreenshareAudio";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -261,6 +265,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const { showControls, header: headerStyle } = useUrlParams();
|
const { showControls, header: headerStyle } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
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 toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||||
@@ -306,10 +312,19 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// [lotus] Apply ML denoise to the mic as a first-class audio processor that
|
// [lotus] Apply ML denoise to the mic as a first-class audio processor that
|
||||||
// survives reconnects (#1 / A7). No-op unless lotusDenoiseSource=1.
|
// survives reconnects (#1 / A7). No-op unless lotusDenoiseSource=1.
|
||||||
useEffect(() => startLotusDenoise(vm), [vm]);
|
useEffect(() => startLotusDenoise(vm), [vm]);
|
||||||
|
useEffect(() => startLotusCallSummary(vm), [vm]);
|
||||||
|
// [lotus #39] Screenshare reminders (window closed / black / sharing alone).
|
||||||
|
useEffect(() => startLotusScreenshareWatch(vm), [vm]);
|
||||||
// [lotus] Handle the host's io.lotus.set_deafen action to silence remote
|
// [lotus] Handle the host's io.lotus.set_deafen action to silence remote
|
||||||
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
||||||
// unless the host sends the action.
|
// unless the host sends the action.
|
||||||
useEffect(() => startLotusDeafen(vm), [vm]);
|
useEffect(() => startLotusDeafen(), []);
|
||||||
|
// [lotus #119] Let the host pick the audio output device from its call bar.
|
||||||
|
const lotusMediaDevices = useMediaDevices();
|
||||||
|
useEffect(
|
||||||
|
() => startLotusAudioOutput(lotusMediaDevices),
|
||||||
|
[lotusMediaDevices],
|
||||||
|
);
|
||||||
|
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
@@ -657,6 +672,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
validIdentities={participants}
|
validIdentities={participants}
|
||||||
muted={muteAllAudio}
|
muted={muteAllAudio}
|
||||||
|
screenshareAudioMuted={muteScreenshareAudio}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { Epoch, ObservableScope } from "../../ObservableScope";
|
|||||||
import {
|
import {
|
||||||
MatrixRTCTransportMissingError,
|
MatrixRTCTransportMissingError,
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
|
SFUTokenRefusedError,
|
||||||
} from "../../../utils/errors";
|
} from "../../../utils/errors";
|
||||||
import * as openIDSFU from "../../../livekit/openIDSFU";
|
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||||
import { customLivekitUrl } from "../../../settings/settings";
|
import { customLivekitUrl } from "../../../settings/settings";
|
||||||
@@ -125,6 +126,46 @@ describe("LocalTransport", () => {
|
|||||||
expect(() => active$.value).toThrow(expectedError);
|
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(
|
||||||
|
async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
throw 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 () => {
|
it("emits preferred transport after OpenID resolves", async () => {
|
||||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||||
mockConfig({
|
mockConfig({
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
|||||||
import { Config } from "../../../config/Config.ts";
|
import { Config } from "../../../config/Config.ts";
|
||||||
import {
|
import {
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
|
SFUTokenRefusedError,
|
||||||
MatrixRTCTransportMissingError,
|
MatrixRTCTransportMissingError,
|
||||||
NoMatrix2AuthorizationService,
|
NoMatrix2AuthorizationService,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
@@ -261,7 +262,9 @@ async function doOpenIdAndJWTFromUrl(
|
|||||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
||||||
if (
|
if (
|
||||||
e instanceof FailToGetOpenIdToken ||
|
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
|
// rethrow as is
|
||||||
return e;
|
return e;
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ function generateRoomOption({
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...liveKitOptions.audioCaptureDefaults,
|
...liveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
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:
|
echoCancellation:
|
||||||
echoCancellationSetting.getValue() &&
|
echoCancellationSetting.getValue() &&
|
||||||
getUrlParams().echoCancellation !== false,
|
getUrlParams().echoCancellation !== false,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Track, type RemoteParticipant } from "livekit-client";
|
import { Track, type RemoteParticipant } from "livekit-client";
|
||||||
import { map, of, switchMap } from "rxjs";
|
import { distinctUntilChanged, map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../Behavior";
|
import { type Behavior } from "../Behavior";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "./ScreenShareViewModel";
|
} from "./ScreenShareViewModel";
|
||||||
import { type ObservableScope } from "../ObservableScope";
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||||
import { observeTrackReference$ } from "../observeTrackReference";
|
import { observeParticipantMedia } from "@livekit/components-core";
|
||||||
|
|
||||||
export interface RemoteScreenShareViewModel
|
export interface RemoteScreenShareViewModel
|
||||||
extends BaseScreenShareViewModel, VolumeControls {
|
extends BaseScreenShareViewModel, VolumeControls {
|
||||||
@@ -58,14 +58,24 @@ export function createRemoteScreenShare(
|
|||||||
videoEnabled$: scope.behavior(
|
videoEnabled$: scope.behavior(
|
||||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||||
),
|
),
|
||||||
|
// [lotus #38] "Has audio" means the sharer publishes screenshare audio AND
|
||||||
|
// hasn't muted it — a muted publication would show a speaker glyph for a
|
||||||
|
// share nobody can hear.
|
||||||
audioEnabled$: scope.behavior(
|
audioEnabled$: scope.behavior(
|
||||||
inputs.participant$.pipe(
|
inputs.participant$.pipe(
|
||||||
switchMap((p) =>
|
switchMap((p) =>
|
||||||
p
|
p
|
||||||
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
? observeParticipantMedia(p).pipe(
|
||||||
: of(null),
|
map(() => {
|
||||||
|
const pub = p.getTrackPublication(
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
return !!pub && !pub.isMuted;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: of(false),
|
||||||
),
|
),
|
||||||
map(Boolean),
|
distinctUntilChanged(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useReactiveState } from "../useReactiveState";
|
|||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import { muteScreenshareAudio$ } from "../lotus/lotusScreenshareAudio";
|
||||||
import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel";
|
import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel";
|
||||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
@@ -322,11 +323,13 @@ const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
|
|||||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||||
const playbackVolume = useBehavior(vm.playbackVolume$);
|
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||||
|
// [lotus #38] The host's screenshare-audio mute (io.lotus.set_deafen) mutes
|
||||||
|
// at the renderer, not through the volume controls; show it as muted too.
|
||||||
|
const lotusMuted = useBehavior(muteScreenshareAudio$);
|
||||||
|
const shownMuted = playbackMuted || lotusMuted;
|
||||||
|
|
||||||
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
|
const VolumeIcon = shownMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||||
const VolumeSolidIcon = playbackMuted
|
const VolumeSolidIcon = shownMuted ? VolumeOffSolidIcon : VolumeOnSolidIcon;
|
||||||
? VolumeOffSolidIcon
|
|
||||||
: VolumeOnSolidIcon;
|
|
||||||
|
|
||||||
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
|
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
|
||||||
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
|
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export enum ErrorCode {
|
|||||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||||
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
|
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
|
||||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
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",
|
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
||||||
SFU_ERROR = "SFU_ERROR",
|
SFU_ERROR = "SFU_ERROR",
|
||||||
UNKNOWN_ERROR = "UNKNOWN_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.
|
* 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 {
|
export class InsufficientCapacityError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
|
|||||||
Reference in New Issue
Block a user