Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9723a21b5 | ||
|
|
9f472fd710 | ||
|
|
e7c27dd8a3 | ||
|
|
33b51e5dc6 | ||
|
|
07def55469 | ||
|
|
4a48ec98d0 | ||
|
|
b9d0ac7cac | ||
|
|
1e34923f6a | ||
|
|
fcb7f8456d | ||
|
|
c2267800be | ||
|
|
5421d545c4 | ||
|
|
2fafa3cf36 | ||
|
|
667230f6e3 | ||
|
|
746917a4c6 | ||
|
|
d881833491 | ||
|
|
021b1881e5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lotusguild/element-call-embedded",
|
"name": "@lotusguild/element-call-embedded",
|
||||||
"version": "0.25.0-lotus.5",
|
"version": "0.25.0-lotus.12",
|
||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE-AGPL-3.0",
|
"LICENSE-AGPL-3.0",
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -309,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(), []);
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user