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
This commit is contained in:
co-authored by
Claude Opus 5
parent
33b51e5dc6
commit
e7c27dd8a3
@@ -56,6 +56,12 @@ export enum LotusWidgetActions {
|
|||||||
SetAudioOutput = "io.lotus.set_audio_output",
|
SetAudioOutput = "io.lotus.set_audio_output",
|
||||||
/** fromWidget: the currently selected output `{ deviceId }` (#119). */
|
/** fromWidget: the currently selected output `{ deviceId }` (#119). */
|
||||||
AudioOutputState = "io.lotus.audio_output_state",
|
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`. */
|
||||||
|
|||||||
@@ -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 { 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("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,123 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const onEnded = (): void => 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;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUnpublished = (pub: LocalTrackPublication): void => {
|
||||||
|
if (pub.source === Track.Source.ScreenShare) 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { startLotusDecorations } from "../lotus/lotusDecorations";
|
|||||||
import { startLotusDenoise } from "../lotus/lotusDenoise";
|
import { startLotusDenoise } from "../lotus/lotusDenoise";
|
||||||
import { startLotusCallSummary } from "../lotus/lotusCallSummary";
|
import { startLotusCallSummary } from "../lotus/lotusCallSummary";
|
||||||
import { startLotusAudioOutput } from "../lotus/lotusAudioOutput";
|
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";
|
||||||
@@ -312,6 +313,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// 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]);
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user