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