diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 7dd85df2..c3df969f 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -56,6 +56,12 @@ export enum LotusWidgetActions { 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`. */ diff --git a/src/lotus/lotusScreenshareWatch.test.ts b/src/lotus/lotusScreenshareWatch.test.ts new file mode 100644 index 00000000..64cbee40 --- /dev/null +++ b/src/lotus/lotusScreenshareWatch.test.ts @@ -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; + emit: (event: string, ...args: unknown[]) => void; + remote: Map; +} => { + const handlers = new Map void)[]>(); + const remote = new Map(); + 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 & { 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(); + }); +}); diff --git a/src/lotus/lotusScreenshareWatch.ts b/src/lotus/lotusScreenshareWatch.ts new file mode 100644 index 00000000..53a7e056 --- /dev/null +++ b/src/lotus/lotusScreenshareWatch.ts @@ -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 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 | 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(); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index eef6f987..c6ff7022 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -37,6 +37,7 @@ 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"; @@ -312,6 +313,8 @@ export const InCallView: FC = ({ // 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.