21 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5 e9723a21b5 chore(lotus): 0.25.0-lotus.12
CI / Build embedded bundle (push) Successful in 1m32s
CI / Publish to Gitea npm registry (push) Successful in 54s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:43 -04:00
Lotus CIandClaude Opus 5 9f472fd710 fix(lotus): report an ended screenshare even when LiveKit unpublishes first (#39)
LiveKit's own ended handler runs before ours and unpublishes the share;
a listener removed during that dispatch never fires. Decide from the
track's readyState on LocalTrackUnpublished as well. Verified end to end:
closing the shared source now toasts in the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:43 -04:00
Lotus CIandClaude Opus 5 e7c27dd8a3 feat(lotus): io.lotus.screenshare_notice — window closed, no frames, sharing alone (#39)
Watches the local screenshare publication and tells the host, once per
share each: the MediaStreamTrack ended (window closed), it has been muted
(no frames — minimised/occluded window) for 15 s, or 30 min of sharing
with nobody else in the call. Detection only; the host renders the
notices. LiveKit already unpublishes an ended share, so nothing lingers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:44:22 -04:00
Lotus CIandClaude Opus 5 33b51e5dc6 feat(lotus): screenshare tile speaker glyph reflects real audio state (#38)
The spotlight screenshare tile already shows a speaker glyph only when the
share publishes audio. Two gaps: a publication the sharer had muted still
counted as audio, and the host's screenshare-audio mute (io.lotus.set_deafen
→ renderer muted) wasn't reflected — the glyph stayed "on" while nothing
played. audioEnabled$ now tracks publication presence AND !isMuted via
observeParticipantMedia; the glyph shows crossed when either the tile's
own mute or the Lotus mute is active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:42:48 -04:00
Lotus CIandClaude Opus 5 07def55469 chore(lotus): 0.25.0-lotus.11
CI / Build embedded bundle (push) Successful in 1m42s
CI / Publish to Gitea npm registry (push) Successful in 47s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:31:33 -04:00
Lotus CIandClaude Opus 5 4a48ec98d0 feat(lotus): io.lotus.set_audio_output — host-driven output device switch (#119)
The embed hides Element Call's footer, so its own output picker is out of
reach; the host's call bar now sends io.lotus.set_audio_output { deviceId }
and the fork applies it via mediaDevices.audioOutput.select(), answering
with io.lotus.audio_output_state { deviceId } on every change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:31:33 -04:00
Lotus CIandClaude Opus 5 b9d0ac7cac chore(lotus): 0.25.0-lotus.10
CI / Build embedded bundle (push) Successful in 1m41s
CI / Publish to Gitea npm registry (push) Successful in 45s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:47:46 -04:00
Lotus CIandClaude Opus 5 1e34923f6a feat(lotus): io.lotus.call_summary — per-call quality readout at hangup (#143)
Tracks the local participant's LiveKit ConnectionQuality (time spent
poor/lost) and Reconnecting events for the life of the in-call view and
sends one io.lotus.call_summary { durationMs, reconnects, poorMs, verdict }
to the host on the first SFU disconnect or on teardown. Nothing is stored
or sent anywhere else. CallQualityTracker is pure and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:39:18 -04:00
Lotus CIandClaude Opus 5 fcb7f8456d chore(lotus): 0.25.0-lotus.9
CI / Build embedded bundle (push) Successful in 1m34s
CI / Publish to Gitea npm registry (push) Successful in 1m7s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:45:23 -04:00
Lotus CIandClaude Opus 5 c2267800be feat(lotus): report speakingWhileMuted for the local participant in io.lotus.call_state (#37)
While the mic is muted LiveKit keeps the capture alive and merely disables the
published MediaStreamTrack, so VAD says nothing. lotusMutedSpeech taps a CLONE
of the published track (post-denoise when the in-source processor is active),
samples RMS at 10 Hz through an AnalyserNode and runs a hysteresis gate (300 ms
of voice on, 800 ms of quiet off, threshold ≈ −36 dBFS). The tap only exists
while a muted mic publication exists; the flag is set on the local entry of
io.lotus.call_state only and is never sent to other participants. Gate is
unit-tested; verified end-to-end: muted with a tone mic → true within 2.5 s,
silence → false, unmute → false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:45:23 -04:00
Lotus CIandClaude Opus 5 5421d545c4 chore(lotus): 0.25.0-lotus.8
CI / Build embedded bundle (push) Successful in 2m34s
CI / Publish to Gitea npm registry (push) Successful in 57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:35:59 -04:00
Lotus CIandClaude Opus 5 2fafa3cf36 test(lotus): oxlint-clean mock in the LocalTransport refusal test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:35:59 -04:00
Lotus CIandClaude Opus 5 667230f6e3 chore(lotus): 0.25.0-lotus.7
CI / Build embedded bundle (push) Failing after 50s
CI / Publish to Gitea npm registry (push) Skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:35:03 -04:00
Lotus CIandClaude Opus 5 746917a4c6 test(lotus): satisfy oxlint require-await in the LocalTransport refusal test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:35:03 -04:00
Lotus CIandClaude Opus 5 d881833491 chore(lotus): 0.25.0-lotus.6
CI / Build embedded bundle (push) Failing after 56s
CI / Publish to Gitea npm registry (push) Skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:04:45 -04:00
Lotus CIandClaude Opus 5 021b1881e5 fix(lotus): LocalTransport must not re-wrap SFUTokenRefusedError
mapAuthErrorToUserFriendlyError turned every non-whitelisted error back into
FailToGetOpenIdToken, so lotus.5's refusal reason still surfaced as the
generic page (caught end-to-end with a routed 403 on /sfu/get). Pass it
through like the other user-facing auth errors. Unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:04:45 -04:00
Lotus CIandClaude Opus 5 1b609d997b chore(lotus): 0.25.0-lotus.5
CI / Build embedded bundle (push) Successful in 1m47s
CI / Publish to Gitea npm registry (push) Successful in 1m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:42:22 -04:00
Lotus CIandClaude Opus 5 d9ac9a0fa4 fix(lotus): show the SFU token service's refusal reason instead of "Something went wrong"
When the voice-limit guard (or any JWT service) answers 403 with a reason —
"This voice channel is full.", "You don't have permission to …" — EC wrapped it
in FailToGetOpenIdToken and the user saw the generic error page with
OPEN_ID_ERROR. A 403 MatrixError with a non-empty `error` now throws
SFUTokenRefusedError ("Can't join this call" + the server's sentence), so the
reason is the description. Other failures are unchanged. Unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:42:22 -04:00
Lotus CIandClaude Opus 5 66bfead7f1 chore(lotus): 0.25.0-lotus.4
CI / Build embedded bundle (push) Successful in 1m44s
CI / Publish to Gitea npm registry (push) Successful in 54s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:54:25 -04:00
Lotus CIandClaude Opus 5 ea579cb998 fix(lotus): screenshare-audio mute survives a re-share — mute via the renderer, not setVolume
"Mute Screenshare Audio" (io.lotus.set_deafen screenshareAudioMuted) used
RemoteParticipant.setVolume(0, ScreenShareAudio). EC's own createVolumeControls
writes volume 1 through the same setter the moment a new screenshare media item
resolves, so when the sharer stopped and re-shared (or a late joiner shared)
the audio came back at full volume while the host button still said
"Unmute Screenshare Audio". Reproduced on the local calls stack with two
headless clients: after a re-share the screen_share_audio element read vol=1.

Now the flag is a global behavior (muteScreenshareAudio$) that
LivekitRoomAudioRenderer turns into the `muted` prop of every
Track.Source.ScreenShareAudio element — the exact mechanism deafen already uses
(pub.setEnabled(false): the server stops sending). Verified via the
RemoteTrackPublication behind each <audio>: the re-published track (new sid)
mounts with enabled=false while muted and re-enables on unmute; deafen +
undeafen leaves it muted; teardown resets the flag so the next call starts
clean. Unit tests updated; renderer test asserts only ScreenShareAudio
elements get muted by the new prop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:54:25 -04:00
Lotus CIandClaude Opus 5 bc0e5ed432 fix(lotus): capture the microphone mono — stereo interfaces published left-only on Firefox
CI / Build embedded bundle (push) Successful in 3m11s
CI / Publish to Gitea npm registry (push) Successful in 1m11s
audioCaptureDefaults set no channelCount, so Firefox captured a 2-channel
audio interface (Scarlett Solo, one XLR mic on input 1) as stereo and,
with browser audio processing off (the ML denoise tier), published it as
is: peers heard the speaker in the left ear only. Chrome downmixes such
captures itself, which is why it only showed on Firefox. Request
channelCount: 1 for mic capture; screenshare audio is captured separately
and is unaffected. Tested. Bumps to 0.25.0-lotus.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-14 21:10:11 -04:00
28 changed files with 1138 additions and 159 deletions
+1 -1
View File
@@ -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",
+1
View File
@@ -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."
+63 -2
View File
@@ -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",
});
});
+12 -1
View File
@@ -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}
/> />
+26 -1
View File
@@ -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;
+10
View File
@@ -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}`),
); );
+1
View File
@@ -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));
+17
View File
@@ -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,
]; ];
+10
View File
@@ -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,
+46
View File
@@ -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);
};
}
+5 -1
View File
@@ -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
View File
@@ -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.
+93
View File
@@ -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",
});
});
});
+144
View File
@@ -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();
};
}
+25 -43
View File
@@ -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
View File
@@ -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);
}; };
+27
View File
@@ -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);
});
+154
View File
@@ -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.050.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(),
);
}
+27
View File
@@ -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$,
);
+171
View File
@@ -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();
});
});
+136
View File
@@ -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
View File
@@ -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,
+15 -5
View File
@@ -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(),
), ),
), ),
}; };
+7 -4
View File
@@ -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]);
+20
View File
@@ -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(