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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user