Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9723a21b5 | ||
|
|
9f472fd710 | ||
|
|
e7c27dd8a3 | ||
|
|
33b51e5dc6 | ||
|
|
07def55469 | ||
|
|
4a48ec98d0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lotusguild/element-call-embedded",
|
||||
"version": "0.25.0-lotus.10",
|
||||
"version": "0.25.0-lotus.12",
|
||||
"files": [
|
||||
"README.md",
|
||||
"LICENSE-AGPL-3.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("LotusWidgetActions", () => {
|
||||
LotusWidgetActions.SetQuality,
|
||||
LotusWidgetActions.Decorations,
|
||||
LotusWidgetActions.SetDeafen,
|
||||
LotusWidgetActions.SetAudioOutput,
|
||||
];
|
||||
|
||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||
|
||||
@@ -52,6 +52,16 @@ export enum LotusWidgetActions {
|
||||
* 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`. */
|
||||
@@ -61,4 +71,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
||||
LotusWidgetActions.SetQuality,
|
||||
LotusWidgetActions.Decorations,
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { startLotusQuality } from "../lotus/lotusQuality";
|
||||
import { startLotusDecorations } from "../lotus/lotusDecorations";
|
||||
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 styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
@@ -311,10 +313,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// survives reconnects (#1 / A7). No-op unless lotusDenoiseSource=1.
|
||||
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
|
||||
// audio (and optionally screenshare audio) at the LiveKit source. No-op
|
||||
// unless the host sends the action.
|
||||
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$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
import { observeParticipantMedia } from "@livekit/components-core";
|
||||
|
||||
export interface RemoteScreenShareViewModel
|
||||
extends BaseScreenShareViewModel, VolumeControls {
|
||||
@@ -58,14 +58,24 @@ export function createRemoteScreenShare(
|
||||
videoEnabled$: scope.behavior(
|
||||
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(
|
||||
inputs.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
||||
: of(null),
|
||||
? observeParticipantMedia(p).pipe(
|
||||
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 { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { muteScreenshareAudio$ } from "../lotus/lotusScreenshareAudio";
|
||||
import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel";
|
||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||
@@ -322,11 +323,13 @@ const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||
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 VolumeSolidIcon = playbackMuted
|
||||
? VolumeOffSolidIcon
|
||||
: VolumeOnSolidIcon;
|
||||
const VolumeIcon = shownMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
const VolumeSolidIcon = shownMuted ? VolumeOffSolidIcon : VolumeOnSolidIcon;
|
||||
|
||||
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
|
||||
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
|
||||
|
||||
Reference in New Issue
Block a user