Join screen, room with screen and camera sharing, live annotation, whiteboard, and the admin panel for kiosks, widgets and branding. Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
547 lines
15 KiB
TypeScript
547 lines
15 KiB
TypeScript
import type {
|
|
ControlEvent,
|
|
DataEnvelope,
|
|
DataTopic,
|
|
NormalizedPoint,
|
|
RoomMode,
|
|
StrokeStyle,
|
|
TopicPayloadMap,
|
|
WhiteboardElement,
|
|
WhiteboardEvent
|
|
} from "@pistation/shared-types";
|
|
import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types";
|
|
import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
|
|
import {
|
|
applyAnnotationEvent,
|
|
createAnnotationState,
|
|
eraseAtPoint,
|
|
makeStrokeId,
|
|
prunePointers,
|
|
publishEnvelope,
|
|
RoomConnection
|
|
} from "@pistation/client-core";
|
|
import {
|
|
LocalTrackPublication,
|
|
LocalVideoTrack,
|
|
RemoteParticipant,
|
|
RemoteTrack,
|
|
RemoteTrackPublication,
|
|
RoomEvent,
|
|
Track
|
|
} from "livekit-client";
|
|
|
|
import { refreshSession } from "./api";
|
|
import type { StoredSession } from "./session";
|
|
|
|
export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
|
|
|
|
export interface RoomParticipant {
|
|
participantId: string;
|
|
displayName: string;
|
|
isSelf: boolean;
|
|
isSharing: boolean;
|
|
isCameraOn: boolean;
|
|
}
|
|
|
|
export interface CameraFeed {
|
|
participantId: string;
|
|
displayName: string;
|
|
track: RemoteTrack;
|
|
}
|
|
|
|
export class RoomController {
|
|
status = $state<ConnectionStatus>("idle");
|
|
mode = $state<RoomMode>("idle");
|
|
annotations = $state<AnnotationState>(createAnnotationState());
|
|
whiteboardElements = $state<WhiteboardElement[]>([]);
|
|
screenTrack = $state<RemoteTrack | null>(null);
|
|
localScreenTrack = $state<LocalVideoTrack | null>(null);
|
|
localCameraTrack = $state<LocalVideoTrack | null>(null);
|
|
cameraFeeds = $state<CameraFeed[]>([]);
|
|
isSharing = $state(false);
|
|
isCameraOn = $state(false);
|
|
facingMode = $state<"user" | "environment">("environment");
|
|
participants = $state<RoomParticipant[]>([]);
|
|
sharingParticipantName = $state<string | null>(null);
|
|
errorMessage = $state<string | null>(null);
|
|
|
|
tool = $state<AnnotationTool>("pen");
|
|
strokeStyle = $state<StrokeStyle>({ ...DEFAULT_STROKE_STYLE });
|
|
isEraser = $state(false);
|
|
isPointerMode = $state(false);
|
|
|
|
private session: StoredSession;
|
|
private connection: RoomConnection | null = null;
|
|
private activeStrokeId: string | null = null;
|
|
private pendingPoints: NormalizedPoint[] = [];
|
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor(session: StoredSession) {
|
|
this.session = session;
|
|
}
|
|
|
|
get canPresent(): boolean {
|
|
return this.session.role !== "viewer";
|
|
}
|
|
|
|
get participantId(): string {
|
|
return this.session.participantId;
|
|
}
|
|
|
|
get kioskName(): string {
|
|
return this.session.kioskName;
|
|
}
|
|
|
|
get role(): string {
|
|
return this.session.role;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
this.connection = new RoomConnection(
|
|
async () => {
|
|
const refreshed = await refreshSession(this.session.sessionId);
|
|
return {
|
|
livekitUrl: this.session.livekitUrl,
|
|
accessToken: refreshed.accessToken
|
|
};
|
|
},
|
|
{
|
|
onStatus: (status) => {
|
|
this.status = status;
|
|
},
|
|
onEnvelope: (envelope) => this.handleEnvelope(envelope),
|
|
onReconnected: () => this.requestRoomState()
|
|
}
|
|
);
|
|
|
|
this.bindTrackEvents();
|
|
await this.connection.start();
|
|
this.requestRoomState();
|
|
this.startPointerPruning();
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
if (this.flushTimer) clearInterval(this.flushTimer);
|
|
this.flushTimer = null;
|
|
await this.connection?.stop();
|
|
this.connection = null;
|
|
}
|
|
|
|
private bindTrackEvents(): void {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
|
|
room
|
|
.on(
|
|
RoomEvent.TrackSubscribed,
|
|
(
|
|
track: RemoteTrack,
|
|
publication: RemoteTrackPublication,
|
|
participant: RemoteParticipant
|
|
) => {
|
|
if (track.kind !== Track.Kind.Video) return;
|
|
|
|
if (publication.source === Track.Source.ScreenShare) {
|
|
this.screenTrack = track;
|
|
}
|
|
|
|
if (publication.source === Track.Source.Camera) {
|
|
this.cameraFeeds = [
|
|
...this.cameraFeeds.filter((feed) => feed.track !== track),
|
|
{
|
|
participantId: participant.identity,
|
|
displayName: participant.name || participant.identity,
|
|
track
|
|
}
|
|
];
|
|
}
|
|
|
|
this.refreshParticipants();
|
|
}
|
|
)
|
|
.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
|
|
if (this.screenTrack === track) this.screenTrack = null;
|
|
this.cameraFeeds = this.cameraFeeds.filter((feed) => feed.track !== track);
|
|
this.refreshParticipants();
|
|
})
|
|
.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
|
|
if (publication.source === Track.Source.ScreenShare) {
|
|
this.isSharing = true;
|
|
this.localScreenTrack = (publication.track as LocalVideoTrack) ?? null;
|
|
}
|
|
|
|
if (publication.source === Track.Source.Camera) {
|
|
this.isCameraOn = true;
|
|
this.localCameraTrack = (publication.track as LocalVideoTrack) ?? null;
|
|
}
|
|
|
|
this.refreshParticipants();
|
|
})
|
|
.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
|
|
if (publication.source === Track.Source.ScreenShare) {
|
|
this.isSharing = false;
|
|
this.localScreenTrack = null;
|
|
}
|
|
|
|
if (publication.source === Track.Source.Camera) {
|
|
this.isCameraOn = false;
|
|
this.localCameraTrack = null;
|
|
}
|
|
|
|
this.refreshParticipants();
|
|
})
|
|
.on(RoomEvent.ParticipantConnected, () => this.refreshParticipants())
|
|
.on(RoomEvent.ParticipantDisconnected, () => this.refreshParticipants())
|
|
.on(RoomEvent.Connected, () => this.refreshParticipants());
|
|
}
|
|
|
|
private refreshParticipants(): void {
|
|
const room = this.connection?.room;
|
|
if (!room) {
|
|
this.participants = [];
|
|
this.sharingParticipantName = null;
|
|
return;
|
|
}
|
|
|
|
const publishes = (
|
|
participant: { trackPublications: Map<string, { source: Track.Source }> },
|
|
source: Track.Source
|
|
) =>
|
|
[...participant.trackPublications.values()].some(
|
|
(publication) => publication.source === source
|
|
);
|
|
|
|
const self: RoomParticipant = {
|
|
participantId: this.session.participantId,
|
|
displayName: this.session.displayName,
|
|
isSelf: true,
|
|
isSharing: this.isSharing,
|
|
isCameraOn: this.isCameraOn
|
|
};
|
|
|
|
const others = [...room.remoteParticipants.values()].map((participant) => ({
|
|
participantId: participant.identity,
|
|
displayName: participant.name || participant.identity,
|
|
isSelf: false,
|
|
isSharing: publishes(participant, Track.Source.ScreenShare),
|
|
isCameraOn: publishes(participant, Track.Source.Camera)
|
|
}));
|
|
|
|
this.participants = [self, ...others];
|
|
this.sharingParticipantName = this.broadcaster?.displayName ?? null;
|
|
}
|
|
|
|
/// The room has a single broadcast slot. Whoever holds it, with either a screen or a
|
|
/// camera, holds it until they stop.
|
|
get broadcaster(): RoomParticipant | null {
|
|
return (
|
|
this.participants.find((participant) => participant.isSharing || participant.isCameraOn) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
get isSomeoneElseSharing(): boolean {
|
|
const holder = this.broadcaster;
|
|
return Boolean(holder && !holder.isSelf);
|
|
}
|
|
|
|
/// One source at a time, per room and per person, so a phone camera cannot quietly ride
|
|
/// alongside a screen share.
|
|
get canStartScreenShare(): boolean {
|
|
return !this.isSomeoneElseSharing && !this.isCameraOn;
|
|
}
|
|
|
|
get canStartCamera(): boolean {
|
|
return !this.isSomeoneElseSharing && !this.isSharing;
|
|
}
|
|
|
|
get blockedReason(): string | null {
|
|
const holder = this.broadcaster;
|
|
if (holder && !holder.isSelf) {
|
|
const what = holder.isSharing ? "sharing a screen" : "sharing a camera";
|
|
return `${holder.displayName} is ${what}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
get participantCount(): number {
|
|
return this.participants.length;
|
|
}
|
|
|
|
async startScreenShare(): Promise<void> {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
|
|
if (!this.canStartScreenShare) {
|
|
this.errorMessage =
|
|
this.blockedReason ?? "Turn your camera off before sharing your screen.";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.errorMessage = null;
|
|
await room.localParticipant.setScreenShareEnabled(true, {
|
|
audio: false,
|
|
contentHint: "detail"
|
|
});
|
|
this.setMode("presentation");
|
|
} catch {
|
|
this.errorMessage = "Screen sharing was blocked or cancelled.";
|
|
}
|
|
}
|
|
|
|
/// Publishes the device camera. Phones default to the rear lens, which is what people
|
|
/// point at a whiteboard or a room, and can be flipped without dropping the track.
|
|
async startCamera(): Promise<void> {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
|
|
if (!this.canStartCamera) {
|
|
this.errorMessage =
|
|
this.blockedReason ?? "Stop sharing your screen before turning the camera on.";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.errorMessage = null;
|
|
await room.localParticipant.setCameraEnabled(true, {
|
|
facingMode: this.facingMode,
|
|
resolution: { width: 1280, height: 720 }
|
|
});
|
|
} catch {
|
|
this.errorMessage = "Could not use the camera. Check the site has permission.";
|
|
this.isCameraOn = false;
|
|
}
|
|
}
|
|
|
|
async stopCamera(): Promise<void> {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
await room.localParticipant.setCameraEnabled(false);
|
|
}
|
|
|
|
async flipCamera(): Promise<void> {
|
|
this.facingMode = this.facingMode === "environment" ? "user" : "environment";
|
|
if (!this.isCameraOn) return;
|
|
|
|
const track = this.localCameraTrack;
|
|
if (!track) return;
|
|
|
|
try {
|
|
await track.restartTrack({ facingMode: this.facingMode });
|
|
} catch {
|
|
this.errorMessage = "This device only has one camera.";
|
|
}
|
|
}
|
|
|
|
async stopScreenShare(): Promise<void> {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
await room.localParticipant.setScreenShareEnabled(false);
|
|
this.setMode("idle");
|
|
}
|
|
|
|
setMode(mode: RoomMode): void {
|
|
if (!this.canPresent) return;
|
|
this.mode = mode;
|
|
this.send("control", { type: "mode.set", mode });
|
|
}
|
|
|
|
beginStroke(point: NormalizedPoint): void {
|
|
if (this.isPointerMode) {
|
|
this.movePointer(point);
|
|
return;
|
|
}
|
|
|
|
if (this.isEraser) {
|
|
this.eraseAt(point);
|
|
return;
|
|
}
|
|
|
|
const strokeId = makeStrokeId();
|
|
this.activeStrokeId = strokeId;
|
|
this.pendingPoints = [];
|
|
|
|
const event = {
|
|
type: "stroke.start" as const,
|
|
strokeId,
|
|
tool: this.tool,
|
|
style: { ...this.strokeStyle },
|
|
point
|
|
};
|
|
|
|
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
|
this.send("annotation", event);
|
|
}
|
|
|
|
extendStroke(point: NormalizedPoint): void {
|
|
if (this.isPointerMode) {
|
|
this.movePointer(point);
|
|
return;
|
|
}
|
|
|
|
if (this.isEraser) {
|
|
this.eraseAt(point);
|
|
return;
|
|
}
|
|
if (!this.activeStrokeId) return;
|
|
|
|
const event = {
|
|
type: "stroke.append" as const,
|
|
strokeId: this.activeStrokeId,
|
|
points: [point]
|
|
};
|
|
|
|
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
|
this.pendingPoints.push(point);
|
|
this.flushPendingPoints();
|
|
}
|
|
|
|
endStroke(): void {
|
|
if (this.isPointerMode) return;
|
|
if (!this.activeStrokeId) return;
|
|
const strokeId = this.activeStrokeId;
|
|
this.activeStrokeId = null;
|
|
this.flushPendingPoints(true);
|
|
|
|
const event = { type: "stroke.end" as const, strokeId };
|
|
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
|
this.send("annotation", event);
|
|
}
|
|
|
|
movePointer(point: NormalizedPoint | null): void {
|
|
this.send(
|
|
"annotation",
|
|
{ type: "pointer.move", point, color: this.strokeStyle.color },
|
|
false
|
|
);
|
|
}
|
|
|
|
clearAnnotations(): void {
|
|
const event = { type: "canvas.clear" as const };
|
|
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
|
this.send("annotation", event);
|
|
}
|
|
|
|
pushWhiteboardElements(elements: WhiteboardElement[]): void {
|
|
if (elements.length === 0) return;
|
|
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, elements);
|
|
this.send("whiteboard", { type: "whiteboard.patch", elements });
|
|
}
|
|
|
|
private eraseAt(point: NormalizedPoint): void {
|
|
const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
|
|
if (strokeIds.length === 0) return;
|
|
|
|
const event = { type: "stroke.erase" as const, strokeIds };
|
|
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
|
this.send("annotation", event);
|
|
}
|
|
|
|
private flushPendingPoints(force = false): void {
|
|
if (this.pendingPoints.length === 0) return;
|
|
if (!force && this.pendingPoints.length < 3) return;
|
|
if (!this.activeStrokeId && !force) return;
|
|
|
|
const strokeId = this.activeStrokeId;
|
|
const points = this.pendingPoints;
|
|
this.pendingPoints = [];
|
|
if (!strokeId) return;
|
|
|
|
this.send("annotation", { type: "stroke.append", strokeId, points }, false);
|
|
}
|
|
|
|
private requestRoomState(): void {
|
|
this.send("control", { type: "room.state.request" });
|
|
this.send("whiteboard", { type: "whiteboard.request" });
|
|
}
|
|
|
|
private startPointerPruning(): void {
|
|
this.flushTimer = setInterval(() => {
|
|
this.annotations = prunePointers(this.annotations, Date.now());
|
|
}, 1000);
|
|
}
|
|
|
|
private send<T extends DataTopic>(
|
|
topic: T,
|
|
payload: TopicPayloadMap[T],
|
|
reliable = true
|
|
): void {
|
|
const room = this.connection?.room;
|
|
if (!room) return;
|
|
publishEnvelope(room, topic, this.session.participantId, payload, reliable);
|
|
}
|
|
|
|
private handleEnvelope(envelope: DataEnvelope): void {
|
|
if (envelope.senderId === this.session.participantId) return;
|
|
|
|
if (isTopic(envelope, "annotation")) {
|
|
this.annotations = applyAnnotationEvent(
|
|
this.annotations,
|
|
envelope.payload,
|
|
envelope.senderId
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isTopic(envelope, "control")) {
|
|
this.handleControlEvent(envelope.payload);
|
|
return;
|
|
}
|
|
|
|
if (isTopic(envelope, "whiteboard")) {
|
|
this.handleWhiteboardEvent(envelope.payload);
|
|
}
|
|
}
|
|
|
|
private handleControlEvent(event: ControlEvent): void {
|
|
if (event.type === "mode.set") {
|
|
this.mode = event.mode;
|
|
return;
|
|
}
|
|
|
|
if (event.type === "room.state") {
|
|
this.mode = event.state.mode;
|
|
return;
|
|
}
|
|
|
|
if (event.type === "room.state.request" && this.isSharing) {
|
|
this.send("control", {
|
|
type: "room.state",
|
|
state: {
|
|
roomName: this.session.roomName,
|
|
mode: this.mode,
|
|
presenterId: this.session.participantId,
|
|
annotationsLocked: false,
|
|
updatedAt: Date.now()
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private handleWhiteboardEvent(event: WhiteboardEvent): void {
|
|
if (event.type === "whiteboard.patch") {
|
|
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "whiteboard.snapshot") {
|
|
this.whiteboardElements = event.elements;
|
|
return;
|
|
}
|
|
|
|
if (event.type === "whiteboard.clear") {
|
|
this.whiteboardElements = [];
|
|
return;
|
|
}
|
|
|
|
if (event.type === "whiteboard.request" && this.whiteboardElements.length > 0) {
|
|
this.send("whiteboard", {
|
|
type: "whiteboard.snapshot",
|
|
elements: this.whiteboardElements,
|
|
backgroundColor: "#0b0d10"
|
|
});
|
|
}
|
|
}
|
|
}
|