/* 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; 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 | 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(); }; }