LiveKit's own ended handler runs before ours and unpublishes the share; a listener removed during that dispatch never fires. Decide from the track's readyState on LocalTrackUnpublished as well. Verified end to end: closing the shared source now toasts in the host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
/*
|
|
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;
|
|
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<typeof setTimeout> | 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();
|
|
};
|
|
}
|