Add shared types and client core packages
The wire contract every client codes against, plus the annotation engine, canvas renderer and connection policy shared by both clients. Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@pistation/client-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./annotations": "./src/annotations.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pistation/shared-types": "workspace:*",
|
||||
"livekit-client": "^2.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { CompletedStroke, NormalizedPoint } from "@pistation/shared-types";
|
||||
|
||||
import type { AnnotationState } from "./annotation-state.js";
|
||||
|
||||
export interface RenderTarget {
|
||||
context: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
/// Participant id to display name, used to label remote pointers.
|
||||
labels?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function renderAnnotations(
|
||||
target: RenderTarget,
|
||||
state: AnnotationState,
|
||||
options: RenderOptions = {}
|
||||
): void {
|
||||
const { context, width, height } = target;
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
for (const stroke of state.strokes) {
|
||||
drawStroke(target, stroke);
|
||||
}
|
||||
|
||||
for (const pointer of state.pointers) {
|
||||
drawPointer(target, pointer.point, pointer.color, options.labels?.get(pointer.participantId));
|
||||
}
|
||||
}
|
||||
|
||||
function drawStroke(target: RenderTarget, stroke: CompletedStroke): void {
|
||||
const { context, width, height } = target;
|
||||
if (stroke.points.length === 0) return;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = stroke.style.opacity;
|
||||
context.strokeStyle = stroke.style.color;
|
||||
context.fillStyle = stroke.style.color;
|
||||
context.lineWidth = Math.max(1, stroke.style.width * width);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
const first = toPixels(stroke.points[0], width, height);
|
||||
const last = toPixels(stroke.points[stroke.points.length - 1], width, height);
|
||||
|
||||
switch (stroke.tool) {
|
||||
case "rectangle":
|
||||
context.strokeRect(first.x, first.y, last.x - first.x, last.y - first.y);
|
||||
break;
|
||||
|
||||
case "ellipse": {
|
||||
const centerX = (first.x + last.x) / 2;
|
||||
const centerY = (first.y + last.y) / 2;
|
||||
context.beginPath();
|
||||
context.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
Math.abs(last.x - first.x) / 2,
|
||||
Math.abs(last.y - first.y) / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
context.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case "arrow":
|
||||
drawArrow(context, first, last, context.lineWidth);
|
||||
break;
|
||||
|
||||
case "laser":
|
||||
drawPointer(target, stroke.points[stroke.points.length - 1], stroke.style.color);
|
||||
break;
|
||||
|
||||
default:
|
||||
drawFreehand(context, stroke.points, width, height);
|
||||
break;
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawFreehand(
|
||||
context: CanvasRenderingContext2D,
|
||||
points: NormalizedPoint[],
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
context.beginPath();
|
||||
const start = toPixels(points[0], width, height);
|
||||
context.moveTo(start.x, start.y);
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = toPixels(points[index - 1], width, height);
|
||||
const current = toPixels(points[index], width, height);
|
||||
const midpoint = { x: (previous.x + current.x) / 2, y: (previous.y + current.y) / 2 };
|
||||
context.quadraticCurveTo(previous.x, previous.y, midpoint.x, midpoint.y);
|
||||
}
|
||||
|
||||
const final = toPixels(points[points.length - 1], width, height);
|
||||
context.lineTo(final.x, final.y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawArrow(
|
||||
context: CanvasRenderingContext2D,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
lineWidth: number
|
||||
): void {
|
||||
const deltaX = to.x - from.x;
|
||||
const deltaY = to.y - from.y;
|
||||
const length = Math.hypot(deltaX, deltaY);
|
||||
if (length < 1) return;
|
||||
|
||||
// Unit vector along the arrow, and the perpendicular used for the head's width.
|
||||
const alongX = deltaX / length;
|
||||
const alongY = deltaY / length;
|
||||
const acrossX = -alongY;
|
||||
const acrossY = alongX;
|
||||
|
||||
// The head grows with the line, but never takes more than part of a short arrow.
|
||||
const headLength = Math.min(length * 0.45, Math.max(lineWidth * 3.6, 10));
|
||||
const halfWidth = headLength * 0.42;
|
||||
|
||||
const baseX = to.x - alongX * headLength;
|
||||
const baseY = to.y - alongY * headLength;
|
||||
|
||||
// Stopping the shaft just inside the head keeps a thick round cap from bulging out
|
||||
// through the point of the arrow.
|
||||
context.beginPath();
|
||||
context.moveTo(from.x, from.y);
|
||||
context.lineTo(baseX + alongX * lineWidth * 0.5, baseY + alongY * lineWidth * 0.5);
|
||||
context.stroke();
|
||||
|
||||
context.save();
|
||||
context.lineJoin = "miter";
|
||||
context.beginPath();
|
||||
context.moveTo(to.x, to.y);
|
||||
context.lineTo(baseX + acrossX * halfWidth, baseY + acrossY * halfWidth);
|
||||
context.lineTo(baseX - acrossX * halfWidth, baseY - acrossY * halfWidth);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawPointer(
|
||||
target: RenderTarget,
|
||||
point: NormalizedPoint,
|
||||
color: string,
|
||||
label?: string
|
||||
): void {
|
||||
const { context, width, height } = target;
|
||||
const position = toPixels(point, width, height);
|
||||
const radius = Math.max(3, width * 0.0028);
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = 0.22;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(position.x, position.y, radius * 2.1, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.beginPath();
|
||||
context.arc(position.x, position.y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
if (label) {
|
||||
drawPointerLabel(context, position, radius, color, label, width);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawPointerLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
position: { x: number; y: number },
|
||||
radius: number,
|
||||
color: string,
|
||||
label: string,
|
||||
width: number
|
||||
): void {
|
||||
const fontSize = Math.max(10, width * 0.0072);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
|
||||
const paddingX = fontSize * 0.5;
|
||||
const paddingY = fontSize * 0.3;
|
||||
const textWidth = context.measureText(label).width;
|
||||
const boxWidth = textWidth + paddingX * 2;
|
||||
const boxHeight = fontSize + paddingY * 2;
|
||||
|
||||
const boxX = position.x + radius * 2.6;
|
||||
const boxY = position.y - boxHeight / 2;
|
||||
|
||||
context.globalAlpha = 0.92;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = "#0b0d10";
|
||||
context.fillText(label, boxX + paddingX, position.y);
|
||||
}
|
||||
|
||||
function toPixels(point: NormalizedPoint, width: number, height: number) {
|
||||
return { x: point.x * width, y: point.y * height };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
AnnotationEvent,
|
||||
CompletedStroke,
|
||||
NormalizedPoint,
|
||||
StrokeStyle
|
||||
} from "@pistation/shared-types";
|
||||
import { isPersistentTool } from "@pistation/shared-types";
|
||||
|
||||
export interface LiveStroke extends CompletedStroke {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface RemotePointer {
|
||||
participantId: string;
|
||||
point: NormalizedPoint;
|
||||
color: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AnnotationState {
|
||||
strokes: LiveStroke[];
|
||||
pointers: RemotePointer[];
|
||||
}
|
||||
|
||||
export const POINTER_TIMEOUT_MS = 4000;
|
||||
|
||||
export function createAnnotationState(): AnnotationState {
|
||||
return { strokes: [], pointers: [] };
|
||||
}
|
||||
|
||||
export function applyAnnotationEvent(
|
||||
state: AnnotationState,
|
||||
event: AnnotationEvent,
|
||||
authorId: string
|
||||
): AnnotationState {
|
||||
switch (event.type) {
|
||||
case "stroke.start":
|
||||
return {
|
||||
...state,
|
||||
strokes: [
|
||||
...state.strokes.filter((stroke) => stroke.strokeId !== event.strokeId),
|
||||
{
|
||||
strokeId: event.strokeId,
|
||||
authorId,
|
||||
tool: event.tool,
|
||||
style: event.style,
|
||||
points: [event.point],
|
||||
isOpen: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
case "stroke.append":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes.map((stroke) =>
|
||||
stroke.strokeId === event.strokeId
|
||||
? { ...stroke, points: [...stroke.points, ...event.points] }
|
||||
: stroke
|
||||
)
|
||||
};
|
||||
|
||||
case "stroke.end":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes
|
||||
.map((stroke) =>
|
||||
stroke.strokeId === event.strokeId ? { ...stroke, isOpen: false } : stroke
|
||||
)
|
||||
.filter((stroke) => isPersistentTool(stroke.tool))
|
||||
};
|
||||
|
||||
case "stroke.erase":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes.filter((stroke) => !event.strokeIds.includes(stroke.strokeId))
|
||||
};
|
||||
|
||||
case "canvas.clear":
|
||||
return { ...state, strokes: [] };
|
||||
|
||||
case "pointer.move": {
|
||||
const others = state.pointers.filter((pointer) => pointer.participantId !== authorId);
|
||||
if (!event.point) return { ...state, pointers: others };
|
||||
return {
|
||||
...state,
|
||||
pointers: [
|
||||
...others,
|
||||
{
|
||||
participantId: authorId,
|
||||
point: event.point,
|
||||
color: event.color,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function prunePointers(state: AnnotationState, now: number): AnnotationState {
|
||||
const fresh = state.pointers.filter((pointer) => now - pointer.updatedAt < POINTER_TIMEOUT_MS);
|
||||
return fresh.length === state.pointers.length ? state : { ...state, pointers: fresh };
|
||||
}
|
||||
|
||||
export function eraseAtPoint(
|
||||
state: AnnotationState,
|
||||
point: NormalizedPoint,
|
||||
radius: number
|
||||
): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const stroke of state.strokes) {
|
||||
if (stroke.points.some((candidate) => distance(candidate, point) <= radius)) {
|
||||
hits.push(stroke.strokeId);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/// Authors with a stroke still in progress, so the UI can say who is drawing right now.
|
||||
export function activeAuthors(state: AnnotationState): string[] {
|
||||
const authors = new Set<string>();
|
||||
for (const stroke of state.strokes) {
|
||||
if (stroke.isOpen) authors.add(stroke.authorId);
|
||||
}
|
||||
return [...authors];
|
||||
}
|
||||
|
||||
export function makeStrokeId(): string {
|
||||
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function withColor(style: StrokeStyle, color: string): StrokeStyle {
|
||||
return { ...style, color };
|
||||
}
|
||||
|
||||
function distance(a: NormalizedPoint, b: NormalizedPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./annotation-renderer.js";
|
||||
export * from "./annotation-state.js";
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DataEnvelope, DataTopic, TopicPayloadMap } from "@pistation/shared-types";
|
||||
import { decodeEnvelope, encodeEnvelope } from "@pistation/shared-types";
|
||||
import { Room, RoomEvent } from "livekit-client";
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "failed";
|
||||
|
||||
export interface Credentials {
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onStatus(status: ConnectionStatus): void;
|
||||
onEnvelope(envelope: DataEnvelope): void;
|
||||
onReconnected?(): void;
|
||||
onConnectionError?(detail: string): void;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000];
|
||||
|
||||
export function createRoom(): Room {
|
||||
return new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
disconnectOnPageLeave: true
|
||||
});
|
||||
}
|
||||
|
||||
export function publishEnvelope<T extends DataTopic>(
|
||||
room: Room,
|
||||
topic: T,
|
||||
senderId: string,
|
||||
payload: TopicPayloadMap[T],
|
||||
reliable = true
|
||||
): void {
|
||||
if (room.state !== "connected") return;
|
||||
const data = encodeEnvelope(topic, senderId, payload);
|
||||
void room.localParticipant.publishData(data, { reliable, topic });
|
||||
}
|
||||
|
||||
export class RoomConnection {
|
||||
readonly room: Room;
|
||||
|
||||
private handlers: ConnectionHandlers;
|
||||
private fetchCredentials: () => Promise<Credentials>;
|
||||
private retryIndex = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
private visibilityListener: (() => void) | null = null;
|
||||
|
||||
constructor(fetchCredentials: () => Promise<Credentials>, handlers: ConnectionHandlers) {
|
||||
this.room = createRoom();
|
||||
this.handlers = handlers;
|
||||
this.fetchCredentials = fetchCredentials;
|
||||
this.bindRoomEvents();
|
||||
this.bindVisibility();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
await this.attempt();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearRetry();
|
||||
if (this.visibilityListener && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", this.visibilityListener);
|
||||
this.visibilityListener = null;
|
||||
}
|
||||
await this.room.disconnect();
|
||||
this.handlers.onStatus("disconnected");
|
||||
}
|
||||
|
||||
private bindRoomEvents(): void {
|
||||
this.room
|
||||
.on(RoomEvent.Connected, () => {
|
||||
this.retryIndex = 0;
|
||||
this.handlers.onStatus("connected");
|
||||
})
|
||||
.on(RoomEvent.Reconnecting, () => this.handlers.onStatus("reconnecting"))
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
this.retryIndex = 0;
|
||||
this.handlers.onStatus("connected");
|
||||
this.handlers.onReconnected?.();
|
||||
})
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onStatus("disconnected");
|
||||
this.scheduleRetry();
|
||||
})
|
||||
.on(RoomEvent.DataReceived, (payload: Uint8Array) => {
|
||||
const envelope = decodeEnvelope(payload);
|
||||
if (envelope) this.handlers.onEnvelope(envelope);
|
||||
});
|
||||
}
|
||||
|
||||
private bindVisibility(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
this.visibilityListener = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (this.stopped) return;
|
||||
if (this.room.state === "connected") return;
|
||||
this.clearRetry();
|
||||
this.retryIndex = 0;
|
||||
void this.attempt();
|
||||
};
|
||||
document.addEventListener("visibilitychange", this.visibilityListener);
|
||||
}
|
||||
|
||||
private async attempt(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onStatus("connecting");
|
||||
|
||||
try {
|
||||
const credentials = await this.fetchCredentials();
|
||||
await this.room.connect(credentials.livekitUrl, credentials.accessToken);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[pistation] livekit connection failed: ${detail}`);
|
||||
this.handlers.onConnectionError?.(detail);
|
||||
this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.stopped || this.retryTimer) return;
|
||||
|
||||
const delay = RETRY_DELAYS_MS[Math.min(this.retryIndex, RETRY_DELAYS_MS.length - 1)];
|
||||
this.retryIndex += 1;
|
||||
this.handlers.onStatus("reconnecting");
|
||||
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.attempt();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./annotation-renderer.js";
|
||||
export * from "./annotation-state.js";
|
||||
export * from "./connection.js";
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@pistation/shared-types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export const ANNOTATION_TOOLS = [
|
||||
"pen",
|
||||
"highlighter",
|
||||
"arrow",
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"laser"
|
||||
] as const;
|
||||
|
||||
export type AnnotationTool = (typeof ANNOTATION_TOOLS)[number];
|
||||
|
||||
export interface NormalizedPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface StrokeStyle {
|
||||
color: string;
|
||||
width: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface StrokeStartEvent {
|
||||
type: "stroke.start";
|
||||
strokeId: string;
|
||||
tool: AnnotationTool;
|
||||
style: StrokeStyle;
|
||||
point: NormalizedPoint;
|
||||
}
|
||||
|
||||
export interface StrokeAppendEvent {
|
||||
type: "stroke.append";
|
||||
strokeId: string;
|
||||
points: NormalizedPoint[];
|
||||
}
|
||||
|
||||
export interface StrokeEndEvent {
|
||||
type: "stroke.end";
|
||||
strokeId: string;
|
||||
}
|
||||
|
||||
export interface StrokeEraseEvent {
|
||||
type: "stroke.erase";
|
||||
strokeIds: string[];
|
||||
}
|
||||
|
||||
export interface CanvasClearEvent {
|
||||
type: "canvas.clear";
|
||||
}
|
||||
|
||||
export interface PointerMoveEvent {
|
||||
type: "pointer.move";
|
||||
point: NormalizedPoint | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type AnnotationEvent =
|
||||
| StrokeStartEvent
|
||||
| StrokeAppendEvent
|
||||
| StrokeEndEvent
|
||||
| StrokeEraseEvent
|
||||
| CanvasClearEvent
|
||||
| PointerMoveEvent;
|
||||
|
||||
export interface CompletedStroke {
|
||||
strokeId: string;
|
||||
authorId: string;
|
||||
tool: AnnotationTool;
|
||||
style: StrokeStyle;
|
||||
points: NormalizedPoint[];
|
||||
}
|
||||
|
||||
export const DEFAULT_STROKE_STYLE: StrokeStyle = {
|
||||
color: "#ff2d55",
|
||||
width: 0.004,
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
export const HIGHLIGHTER_STYLE: StrokeStyle = {
|
||||
color: "#ffd60a",
|
||||
width: 0.018,
|
||||
opacity: 0.35
|
||||
};
|
||||
|
||||
export function isPersistentTool(tool: AnnotationTool): boolean {
|
||||
return tool !== "laser";
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Kiosk, KioskLayout } from "./kiosk.js";
|
||||
import type { ParticipantRole } from "./room.js";
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface KioskRegisterRequest {
|
||||
enrollmentToken: string;
|
||||
hardwareId: string;
|
||||
}
|
||||
|
||||
export interface KioskRegisterResponse {
|
||||
kioskId: string;
|
||||
kioskToken: string;
|
||||
roomName: string;
|
||||
livekitUrl: string;
|
||||
rotationSeconds: number;
|
||||
}
|
||||
|
||||
export interface KioskPinResponse {
|
||||
pin: string;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface KioskSessionResponse {
|
||||
roomName: string;
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
pin: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface JoinResponse {
|
||||
sessionId: string;
|
||||
roomName: string;
|
||||
kioskName: string;
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
role: ParticipantRole;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface SessionRefreshRequest {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface SessionRefreshResponse {
|
||||
roomName: string;
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
role: ParticipantRole;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AdminLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
accessToken: string;
|
||||
email: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CreateKioskRequest {
|
||||
name: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface CreateKioskResponse {
|
||||
kiosk: Kiosk;
|
||||
enrollmentToken: string;
|
||||
}
|
||||
|
||||
export interface KioskDetailResponse {
|
||||
kiosk: Kiosk;
|
||||
layout: KioskLayout;
|
||||
currentPin: string | null;
|
||||
}
|
||||
|
||||
export interface KioskListResponse {
|
||||
kiosks: Kiosk[];
|
||||
}
|
||||
|
||||
export interface UpdateLayoutRequest {
|
||||
layout: KioskLayout;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AnnotationEvent } from "./annotation.js";
|
||||
import type { ControlEvent } from "./control.js";
|
||||
import type { WhiteboardEvent } from "./whiteboard.js";
|
||||
|
||||
export const DATA_TOPICS = ["annotation", "control", "whiteboard"] as const;
|
||||
|
||||
export type DataTopic = (typeof DATA_TOPICS)[number];
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export interface TopicPayloadMap {
|
||||
annotation: AnnotationEvent;
|
||||
control: ControlEvent;
|
||||
whiteboard: WhiteboardEvent;
|
||||
}
|
||||
|
||||
export interface DataEnvelope<T extends DataTopic = DataTopic> {
|
||||
version: number;
|
||||
topic: T;
|
||||
senderId: string;
|
||||
sentAt: number;
|
||||
payload: TopicPayloadMap[T];
|
||||
}
|
||||
|
||||
export function encodeEnvelope<T extends DataTopic>(
|
||||
topic: T,
|
||||
senderId: string,
|
||||
payload: TopicPayloadMap[T]
|
||||
): Uint8Array {
|
||||
const envelope: DataEnvelope<T> = {
|
||||
version: PROTOCOL_VERSION,
|
||||
topic,
|
||||
senderId,
|
||||
sentAt: Date.now(),
|
||||
payload
|
||||
};
|
||||
return new TextEncoder().encode(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
export function decodeEnvelope(data: Uint8Array): DataEnvelope | null {
|
||||
return parseEnvelope(new TextDecoder().decode(data));
|
||||
}
|
||||
|
||||
export function parseEnvelope(json: string): DataEnvelope | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as DataEnvelope;
|
||||
if (parsed.version !== PROTOCOL_VERSION) return null;
|
||||
if (!DATA_TOPICS.includes(parsed.topic)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTopic<T extends DataTopic>(
|
||||
envelope: DataEnvelope,
|
||||
topic: T
|
||||
): envelope is DataEnvelope<T> {
|
||||
return envelope.topic === topic;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RoomMode, RoomState } from "./room.js";
|
||||
|
||||
export interface ModeSetEvent {
|
||||
type: "mode.set";
|
||||
mode: RoomMode;
|
||||
}
|
||||
|
||||
export interface PresenterSetEvent {
|
||||
type: "presenter.set";
|
||||
presenterId: string | null;
|
||||
}
|
||||
|
||||
export interface AnnotationLockEvent {
|
||||
type: "annotation.lock";
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface RoomStateEvent {
|
||||
type: "room.state";
|
||||
state: RoomState;
|
||||
}
|
||||
|
||||
export interface RoomStateRequestEvent {
|
||||
type: "room.state.request";
|
||||
}
|
||||
|
||||
export type ControlEvent =
|
||||
| ModeSetEvent
|
||||
| PresenterSetEvent
|
||||
| AnnotationLockEvent
|
||||
| RoomStateEvent
|
||||
| RoomStateRequestEvent;
|
||||
@@ -0,0 +1,28 @@
|
||||
let sequence = 0;
|
||||
|
||||
export function createId(prefix: string): string {
|
||||
sequence += 1;
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}-${Date.now().toString(36)}-${sequence.toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
export function ensureUniqueIds<T>(
|
||||
items: T[],
|
||||
getId: (item: T) => string,
|
||||
withId: (item: T, id: string) => T,
|
||||
prefix: string
|
||||
): T[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.map((item) => {
|
||||
const id = getId(item);
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
return item;
|
||||
}
|
||||
|
||||
const replacement = createId(prefix);
|
||||
seen.add(replacement);
|
||||
return withId(item, replacement);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./annotation.js";
|
||||
export * from "./api.js";
|
||||
export * from "./channel.js";
|
||||
export * from "./control.js";
|
||||
export * from "./ids.js";
|
||||
export * from "./kiosk.js";
|
||||
export * from "./layout-grid.js";
|
||||
export * from "./time-zone.js";
|
||||
export * from "./night.js";
|
||||
export * from "./organization.js";
|
||||
export * from "./room.js";
|
||||
export * from "./whiteboard.js";
|
||||
export * from "./widget.js";
|
||||
export * from "./widget-builder.js";
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { NightModeSettings } from "./night.js";
|
||||
import type { CustomWidgetDefinition } from "./widget-builder.js";
|
||||
import type { Widget } from "./widget.js";
|
||||
|
||||
export const KIOSK_STATUSES = ["online", "offline"] as const;
|
||||
|
||||
export type KioskStatus = (typeof KIOSK_STATUSES)[number];
|
||||
|
||||
export interface WifiMetrics {
|
||||
interface: string;
|
||||
linkQuality: number;
|
||||
signalDbm: number;
|
||||
}
|
||||
|
||||
export interface KioskMetrics {
|
||||
cpuPercent: number | null;
|
||||
memoryUsedBytes: number;
|
||||
memoryTotalBytes: number;
|
||||
uptimeSeconds: number;
|
||||
temperatureCelsius: number | null;
|
||||
wifi: WifiMetrics | null;
|
||||
}
|
||||
|
||||
export interface Kiosk {
|
||||
kioskId: string;
|
||||
name: string;
|
||||
location: string;
|
||||
roomName: string;
|
||||
status: KioskStatus;
|
||||
lastSeenAt: number | null;
|
||||
createdAt: number;
|
||||
metrics: KioskMetrics | null;
|
||||
metricsAt: number | null;
|
||||
}
|
||||
|
||||
export function signalLabel(signalDbm: number): "excellent" | "good" | "weak" | "poor" {
|
||||
if (signalDbm >= -55) return "excellent";
|
||||
if (signalDbm >= -67) return "good";
|
||||
if (signalDbm >= -75) return "weak";
|
||||
return "poor";
|
||||
}
|
||||
|
||||
/// Rough strength as a percentage. Wi-Fi signal is logarithmic and roughly spans -100 dBm
|
||||
/// for unusable to -50 dBm for excellent, which is the range this maps onto.
|
||||
export function signalPercent(signalDbm: number): number {
|
||||
return Math.round(Math.min(100, Math.max(0, 2 * (signalDbm + 100))));
|
||||
}
|
||||
|
||||
export function signalAdvice(signalDbm: number): string {
|
||||
switch (signalLabel(signalDbm)) {
|
||||
case "excellent":
|
||||
return "Strong signal";
|
||||
case "good":
|
||||
return "Good signal";
|
||||
case "weak":
|
||||
return "Weak, video may stutter";
|
||||
default:
|
||||
return "Too weak, move the screen or the access point";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export interface KioskBackground {
|
||||
/// Retained so layouts saved before rotation existed still resolve. The server folds a
|
||||
/// non empty value into `images` on load, so clients should read `images`.
|
||||
imageUrl: string;
|
||||
images: string[];
|
||||
rotationSeconds: number;
|
||||
fit: "cover" | "contain";
|
||||
dim: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKGROUND: KioskBackground = {
|
||||
imageUrl: "",
|
||||
images: [],
|
||||
rotationSeconds: 60,
|
||||
fit: "cover",
|
||||
dim: 0.35
|
||||
};
|
||||
|
||||
export const MIN_WALLPAPER_ROTATION_SECONDS = 5;
|
||||
|
||||
export const DEFAULT_WIDGET_OPACITY = 0.5;
|
||||
|
||||
export interface KioskLayout {
|
||||
kioskId: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
widgetOpacity: number;
|
||||
background: KioskBackground;
|
||||
nightMode: NightModeSettings;
|
||||
widgets: Widget[];
|
||||
customDefinitions: CustomWidgetDefinition[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function resolveMediaUrl(baseUrl: string, url: string): string {
|
||||
if (!url) return "";
|
||||
if (/^(https?:|data:|blob:)/.test(url)) return url;
|
||||
return `${baseUrl.replace(/\/$/, "")}${url.startsWith("/") ? url : `/${url}`}`;
|
||||
}
|
||||
|
||||
export const PIN_LENGTH = 6;
|
||||
export const PIN_ROTATION_SECONDS = 45;
|
||||
export const PIN_GRACE_SECONDS = 15;
|
||||
|
||||
export function isValidPin(pin: string): boolean {
|
||||
return new RegExp(`^\\d{${PIN_LENGTH}}$`).test(pin);
|
||||
}
|
||||
|
||||
export function formatPin(pin: string): string {
|
||||
if (pin.length !== PIN_LENGTH) return pin;
|
||||
return `${pin.slice(0, 3)} ${pin.slice(3)}`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Widget, WidgetPlacement } from "./widget.js";
|
||||
import { WIDGET_GRID_COLUMNS, WIDGET_GRID_ROWS } from "./widget.js";
|
||||
|
||||
export type Occupancy = boolean[][];
|
||||
|
||||
export function buildOccupancy(widgets: Widget[], ignoreWidgetId?: string): Occupancy {
|
||||
const grid: Occupancy = Array.from({ length: WIDGET_GRID_ROWS }, () =>
|
||||
Array.from({ length: WIDGET_GRID_COLUMNS }, () => false)
|
||||
);
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.widgetId === ignoreWidgetId) continue;
|
||||
|
||||
const { column, row, columnSpan, rowSpan } = widget.placement;
|
||||
for (let y = row - 1; y < row - 1 + rowSpan; y += 1) {
|
||||
for (let x = column - 1; x < column - 1 + columnSpan; x += 1) {
|
||||
if (y >= 0 && y < WIDGET_GRID_ROWS && x >= 0 && x < WIDGET_GRID_COLUMNS) {
|
||||
grid[y][x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function isWithinGrid(placement: WidgetPlacement): boolean {
|
||||
return (
|
||||
placement.column >= 1 &&
|
||||
placement.row >= 1 &&
|
||||
placement.columnSpan >= 1 &&
|
||||
placement.rowSpan >= 1 &&
|
||||
placement.column + placement.columnSpan - 1 <= WIDGET_GRID_COLUMNS &&
|
||||
placement.row + placement.rowSpan - 1 <= WIDGET_GRID_ROWS
|
||||
);
|
||||
}
|
||||
|
||||
export function isAreaFree(occupancy: Occupancy, placement: WidgetPlacement): boolean {
|
||||
if (!isWithinGrid(placement)) return false;
|
||||
|
||||
for (let y = placement.row - 1; y < placement.row - 1 + placement.rowSpan; y += 1) {
|
||||
for (let x = placement.column - 1; x < placement.column - 1 + placement.columnSpan; x += 1) {
|
||||
if (occupancy[y][x]) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampPlacement(placement: WidgetPlacement): WidgetPlacement {
|
||||
const columnSpan = Math.min(Math.max(1, placement.columnSpan), WIDGET_GRID_COLUMNS);
|
||||
const rowSpan = Math.min(Math.max(1, placement.rowSpan), WIDGET_GRID_ROWS);
|
||||
|
||||
return {
|
||||
columnSpan,
|
||||
rowSpan,
|
||||
column: Math.min(Math.max(1, placement.column), WIDGET_GRID_COLUMNS - columnSpan + 1),
|
||||
row: Math.min(Math.max(1, placement.row), WIDGET_GRID_ROWS - rowSpan + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function findFreePlacement(
|
||||
widgets: Widget[],
|
||||
columnSpan: number,
|
||||
rowSpan: number
|
||||
): WidgetPlacement | null {
|
||||
const occupancy = buildOccupancy(widgets);
|
||||
|
||||
for (const span of shrinkingSpans(columnSpan, rowSpan)) {
|
||||
for (let row = 1; row <= WIDGET_GRID_ROWS - span.rowSpan + 1; row += 1) {
|
||||
for (let column = 1; column <= WIDGET_GRID_COLUMNS - span.columnSpan + 1; column += 1) {
|
||||
const candidate = { column, row, ...span };
|
||||
if (isAreaFree(occupancy, candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shrinkingSpans(columnSpan: number, rowSpan: number) {
|
||||
const spans: { columnSpan: number; rowSpan: number }[] = [];
|
||||
let columns = Math.min(Math.max(1, columnSpan), WIDGET_GRID_COLUMNS);
|
||||
let rows = Math.min(Math.max(1, rowSpan), WIDGET_GRID_ROWS);
|
||||
|
||||
while (columns >= 1 && rows >= 1) {
|
||||
spans.push({ columnSpan: columns, rowSpan: rows });
|
||||
if (columns === 1 && rows === 1) break;
|
||||
if (columns >= rows) columns -= 1;
|
||||
else rows -= 1;
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface NightModeSettings {
|
||||
enabled: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeZone: string;
|
||||
showPin: boolean;
|
||||
brightness: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_NIGHT_MODE: NightModeSettings = {
|
||||
enabled: false,
|
||||
startTime: "22:00",
|
||||
endTime: "06:30",
|
||||
timeZone: "",
|
||||
showPin: true,
|
||||
brightness: 0.45
|
||||
};
|
||||
|
||||
export function parseTimeOfDay(value: string): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function formatTimeOfDay(minutesOfDay: number): string {
|
||||
const normalized = ((minutesOfDay % 1440) + 1440) % 1440;
|
||||
const hours = Math.floor(normalized / 60);
|
||||
const minutes = normalized % 60;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function minutesOfDayIn(timeZone: string, now: Date): number {
|
||||
if (!timeZone) return now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23"
|
||||
}).formatToParts(now);
|
||||
|
||||
const hours = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
|
||||
const minutes = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
|
||||
return hours * 60 + minutes;
|
||||
} catch {
|
||||
return now.getHours() * 60 + now.getMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
export function isNightModeActive(
|
||||
settings: NightModeSettings,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
|
||||
const start = parseTimeOfDay(settings.startTime);
|
||||
const end = parseTimeOfDay(settings.endTime);
|
||||
if (start === null || end === null || start === end) return false;
|
||||
|
||||
const current = minutesOfDayIn(settings.timeZone, now);
|
||||
|
||||
if (start < end) {
|
||||
return current >= start && current < end;
|
||||
}
|
||||
|
||||
return current >= start || current < end;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface BrandLink {
|
||||
linkId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const LANDING_MODES = ["full", "join"] as const;
|
||||
|
||||
export type LandingMode = (typeof LANDING_MODES)[number];
|
||||
|
||||
export interface BrandTheme {
|
||||
surface0: string;
|
||||
surface1: string;
|
||||
surface2: string;
|
||||
surface3: string;
|
||||
ink0: string;
|
||||
ink1: string;
|
||||
ink2: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: BrandTheme = {
|
||||
surface0: "#0b0d10",
|
||||
surface1: "#14181d",
|
||||
surface2: "#1c2229",
|
||||
surface3: "#262e37",
|
||||
ink0: "#f4f6f8",
|
||||
ink1: "#a8b3c0",
|
||||
ink2: "#6b7885"
|
||||
};
|
||||
|
||||
export interface OrganizationBranding {
|
||||
name: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
accentColor: string;
|
||||
joinLabel: string;
|
||||
footerNote: string;
|
||||
showSourceLink: boolean;
|
||||
landingMode: LandingMode;
|
||||
theme: BrandTheme;
|
||||
links: BrandLink[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/// Inline style declaring the palette as custom properties, so every Tailwind utility
|
||||
/// built on these tokens picks up the organisation's colours without touching components.
|
||||
export function themeStyle(branding: OrganizationBranding): string {
|
||||
const theme = { ...DEFAULT_THEME, ...(branding.theme ?? {}) };
|
||||
|
||||
return [
|
||||
`--color-surface-0: ${theme.surface0}`,
|
||||
`--color-surface-1: ${theme.surface1}`,
|
||||
`--color-surface-2: ${theme.surface2}`,
|
||||
`--color-surface-3: ${theme.surface3}`,
|
||||
`--color-ink-0: ${theme.ink0}`,
|
||||
`--color-ink-1: ${theme.ink1}`,
|
||||
`--color-ink-2: ${theme.ink2}`,
|
||||
`--color-accent: ${branding.accentColor}`,
|
||||
`--color-accent-strong: ${branding.accentColor}`,
|
||||
`background-color: ${theme.surface0}`,
|
||||
`color: ${theme.ink0}`
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export const DEFAULT_BRANDING: OrganizationBranding = {
|
||||
name: "PiStation",
|
||||
headline: "Any screen becomes a shared screen.",
|
||||
description:
|
||||
"Type the code shown on screen to present, draw on what is being shown, or open a " +
|
||||
"whiteboard together. No accounts, no installs, and nothing leaves the network it runs on.",
|
||||
logoUrl: "",
|
||||
accentColor: "#4f7cff",
|
||||
joinLabel: "Enter the code on screen",
|
||||
footerNote: "",
|
||||
showSourceLink: true,
|
||||
landingMode: "full",
|
||||
theme: DEFAULT_THEME,
|
||||
links: [],
|
||||
updatedAt: 0
|
||||
};
|
||||
|
||||
export function withBrandingDefaults(
|
||||
branding: Partial<OrganizationBranding> | null | undefined
|
||||
): OrganizationBranding {
|
||||
if (!branding) return { ...DEFAULT_BRANDING, theme: { ...DEFAULT_THEME } };
|
||||
|
||||
return {
|
||||
...DEFAULT_BRANDING,
|
||||
...branding,
|
||||
theme: { ...DEFAULT_THEME, ...(branding.theme ?? {}) },
|
||||
links: branding.links ?? []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const ROOM_MODES = ["idle", "presentation", "whiteboard"] as const;
|
||||
|
||||
export type RoomMode = (typeof ROOM_MODES)[number];
|
||||
|
||||
export const PARTICIPANT_ROLES = ["kiosk", "presenter", "viewer", "admin"] as const;
|
||||
|
||||
export type ParticipantRole = (typeof PARTICIPANT_ROLES)[number];
|
||||
|
||||
export interface ParticipantIdentity {
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
role: ParticipantRole;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomName: string;
|
||||
mode: RoomMode;
|
||||
presenterId: string | null;
|
||||
annotationsLocked: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function canPublishScreen(role: ParticipantRole): boolean {
|
||||
return role === "presenter" || role === "admin";
|
||||
}
|
||||
|
||||
export function canChangeMode(role: ParticipantRole): boolean {
|
||||
return role === "presenter" || role === "admin";
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const validationCache = new Map<string, boolean>();
|
||||
|
||||
export function isValidTimeZone(timeZone: string): boolean {
|
||||
if (!timeZone) return false;
|
||||
|
||||
const cached = validationCache.get(timeZone);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let valid = true;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-GB", { timeZone }).format(new Date());
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
|
||||
validationCache.set(timeZone, valid);
|
||||
return valid;
|
||||
}
|
||||
|
||||
export function safeTimeZone(timeZone: string): string | undefined {
|
||||
return isValidTimeZone(timeZone) ? timeZone : undefined;
|
||||
}
|
||||
|
||||
export function localTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
export function listTimeZones(): string[] {
|
||||
const supported = (
|
||||
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
|
||||
).supportedValuesOf;
|
||||
|
||||
if (typeof supported === "function") {
|
||||
try {
|
||||
return supported.call(Intl, "timeZone");
|
||||
} catch {
|
||||
return COMMON_TIME_ZONES;
|
||||
}
|
||||
}
|
||||
|
||||
return COMMON_TIME_ZONES;
|
||||
}
|
||||
|
||||
export const COMMON_TIME_ZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Phoenix",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
"America/Toronto",
|
||||
"America/Mexico_City",
|
||||
"America/Sao_Paulo",
|
||||
"Europe/London",
|
||||
"Europe/Dublin",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Madrid",
|
||||
"Europe/Rome",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Athens",
|
||||
"Europe/Moscow",
|
||||
"Africa/Cairo",
|
||||
"Africa/Lagos",
|
||||
"Africa/Johannesburg",
|
||||
"Asia/Dubai",
|
||||
"Asia/Karachi",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Singapore",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Seoul",
|
||||
"Australia/Perth",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland"
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface WhiteboardElement {
|
||||
id: string;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
isDeleted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WhiteboardPatchEvent {
|
||||
type: "whiteboard.patch";
|
||||
elements: WhiteboardElement[];
|
||||
}
|
||||
|
||||
export interface WhiteboardSnapshotEvent {
|
||||
type: "whiteboard.snapshot";
|
||||
elements: WhiteboardElement[];
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface WhiteboardRequestEvent {
|
||||
type: "whiteboard.request";
|
||||
}
|
||||
|
||||
export interface WhiteboardClearEvent {
|
||||
type: "whiteboard.clear";
|
||||
}
|
||||
|
||||
export type WhiteboardEvent =
|
||||
| WhiteboardPatchEvent
|
||||
| WhiteboardSnapshotEvent
|
||||
| WhiteboardRequestEvent
|
||||
| WhiteboardClearEvent;
|
||||
|
||||
export function mergeWhiteboardElements(
|
||||
current: WhiteboardElement[],
|
||||
incoming: WhiteboardElement[]
|
||||
): WhiteboardElement[] {
|
||||
const byId = new Map<string, WhiteboardElement>();
|
||||
for (const element of current) {
|
||||
byId.set(element.id, element);
|
||||
}
|
||||
for (const element of incoming) {
|
||||
const existing = byId.get(element.id);
|
||||
if (!existing || isNewerElement(element, existing)) {
|
||||
byId.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean {
|
||||
if (candidate.version !== existing.version) {
|
||||
return candidate.version > existing.version;
|
||||
}
|
||||
return candidate.versionNonce < existing.versionNonce;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const WIDGET_BLOCK_KINDS = ["heading", "text", "metric", "list", "image", "divider"] as const;
|
||||
|
||||
export type WidgetBlockKind = (typeof WIDGET_BLOCK_KINDS)[number];
|
||||
|
||||
export interface HeadingBlock {
|
||||
blockId: string;
|
||||
kind: "heading";
|
||||
template: string;
|
||||
}
|
||||
|
||||
export interface TextBlock {
|
||||
blockId: string;
|
||||
kind: "text";
|
||||
template: string;
|
||||
}
|
||||
|
||||
export interface MetricBlock {
|
||||
blockId: string;
|
||||
kind: "metric";
|
||||
labelTemplate: string;
|
||||
valueTemplate: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface ListBlock {
|
||||
blockId: string;
|
||||
kind: "list";
|
||||
sourcePath: string;
|
||||
itemTemplate: string;
|
||||
maxItems: number;
|
||||
}
|
||||
|
||||
export interface ImageBlock {
|
||||
blockId: string;
|
||||
kind: "image";
|
||||
urlTemplate: string;
|
||||
fit: "cover" | "contain";
|
||||
}
|
||||
|
||||
export interface DividerBlock {
|
||||
blockId: string;
|
||||
kind: "divider";
|
||||
}
|
||||
|
||||
export type WidgetBlock =
|
||||
| HeadingBlock
|
||||
| TextBlock
|
||||
| MetricBlock
|
||||
| ListBlock
|
||||
| ImageBlock
|
||||
| DividerBlock;
|
||||
|
||||
export interface WidgetDataSource {
|
||||
url: string;
|
||||
refreshSeconds: number;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export interface CustomWidgetDefinition {
|
||||
definitionId: string;
|
||||
name: string;
|
||||
blocks: WidgetBlock[];
|
||||
dataSource: WidgetDataSource | null;
|
||||
accentColor: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const TEMPLATE_TOKEN = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
export function resolvePath(source: unknown, path: string): unknown {
|
||||
if (!path) return source;
|
||||
let current: unknown = source;
|
||||
for (const segment of path.split(".")) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
const index = Number(segment);
|
||||
if (Array.isArray(current) && Number.isInteger(index)) {
|
||||
current = current[index];
|
||||
} else if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function renderTemplate(template: string, data: unknown): string {
|
||||
return template.replace(TEMPLATE_TOKEN, (_match, path: string) => {
|
||||
const value = resolvePath(data, path);
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmptyDefinition(definitionId: string): CustomWidgetDefinition {
|
||||
return {
|
||||
definitionId,
|
||||
name: "Untitled widget",
|
||||
blocks: [],
|
||||
dataSource: null,
|
||||
accentColor: "#4f7cff",
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
export const BUILTIN_WIDGET_KINDS = [
|
||||
"clock",
|
||||
"weather",
|
||||
"pin",
|
||||
"text",
|
||||
"image",
|
||||
"agenda",
|
||||
"custom"
|
||||
] as const;
|
||||
|
||||
export type WidgetKind = (typeof BUILTIN_WIDGET_KINDS)[number];
|
||||
|
||||
export const WIDGET_GRID_COLUMNS = 12;
|
||||
export const WIDGET_GRID_ROWS = 8;
|
||||
|
||||
export interface WidgetPlacement {
|
||||
column: number;
|
||||
row: number;
|
||||
columnSpan: number;
|
||||
rowSpan: number;
|
||||
}
|
||||
|
||||
export interface ClockSettings {
|
||||
timeZone: string;
|
||||
showSeconds: boolean;
|
||||
showDate: boolean;
|
||||
hour12: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherSettings {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationLabel: string;
|
||||
units: "metric" | "imperial";
|
||||
}
|
||||
|
||||
export interface PinSettings {
|
||||
label: string;
|
||||
showJoinUrl: boolean;
|
||||
}
|
||||
|
||||
export interface TextSettings {
|
||||
heading: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ImageSettings {
|
||||
imageUrl: string;
|
||||
fit: "cover" | "contain";
|
||||
}
|
||||
|
||||
export interface AgendaSettings {
|
||||
heading: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface CustomSettings {
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface WidgetSettingsMap {
|
||||
clock: ClockSettings;
|
||||
weather: WeatherSettings;
|
||||
pin: PinSettings;
|
||||
text: TextSettings;
|
||||
image: ImageSettings;
|
||||
agenda: AgendaSettings;
|
||||
custom: CustomSettings;
|
||||
}
|
||||
|
||||
export const WIDGET_ALIGNMENTS = ["start", "center", "end"] as const;
|
||||
|
||||
export type WidgetAlign = (typeof WIDGET_ALIGNMENTS)[number];
|
||||
|
||||
export interface WidgetStyle {
|
||||
padding: number;
|
||||
align: WidgetAlign;
|
||||
verticalAlign: WidgetAlign;
|
||||
backgroundColor: string;
|
||||
opacity: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_WIDGET_STYLE: WidgetStyle = {
|
||||
padding: 5,
|
||||
align: "start",
|
||||
verticalAlign: "center",
|
||||
backgroundColor: "",
|
||||
opacity: null
|
||||
};
|
||||
|
||||
function styleFor(overrides: Partial<WidgetStyle>): WidgetStyle {
|
||||
return { ...DEFAULT_WIDGET_STYLE, ...overrides };
|
||||
}
|
||||
|
||||
export const DEFAULT_WIDGET_STYLES: Record<WidgetKind, WidgetStyle> = {
|
||||
clock: styleFor({}),
|
||||
weather: styleFor({}),
|
||||
pin: styleFor({ align: "center" }),
|
||||
text: styleFor({}),
|
||||
image: styleFor({ padding: 0, align: "center", opacity: 0 }),
|
||||
agenda: styleFor({}),
|
||||
custom: styleFor({})
|
||||
};
|
||||
|
||||
export function toCssColor(hexColor: string, opacity: number): string {
|
||||
const normalized = hexColor.replace("#", "");
|
||||
if (normalized.length !== 3 && normalized.length !== 6) {
|
||||
return `rgb(255 255 255 / ${opacity})`;
|
||||
}
|
||||
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split("")
|
||||
.map((character) => character + character)
|
||||
.join("")
|
||||
: normalized;
|
||||
|
||||
const red = Number.parseInt(expanded.slice(0, 2), 16);
|
||||
const green = Number.parseInt(expanded.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(expanded.slice(4, 6), 16);
|
||||
|
||||
return `rgb(${red} ${green} ${blue} / ${opacity})`;
|
||||
}
|
||||
|
||||
export function toFlexAlign(align: WidgetAlign): string {
|
||||
if (align === "center") return "center";
|
||||
return align === "end" ? "flex-end" : "flex-start";
|
||||
}
|
||||
|
||||
export function toTextAlign(align: WidgetAlign): string {
|
||||
if (align === "center") return "center";
|
||||
return align === "end" ? "right" : "left";
|
||||
}
|
||||
|
||||
export interface Widget<K extends WidgetKind = WidgetKind> {
|
||||
widgetId: string;
|
||||
kind: K;
|
||||
placement: WidgetPlacement;
|
||||
settings: WidgetSettingsMap[K];
|
||||
style: WidgetStyle;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_WIDGET_SETTINGS: WidgetSettingsMap = {
|
||||
clock: { timeZone: "UTC", showSeconds: false, showDate: true, hour12: true },
|
||||
weather: {
|
||||
latitude: 38.8304,
|
||||
longitude: -77.3078,
|
||||
locationLabel: "Fairfax",
|
||||
units: "imperial"
|
||||
},
|
||||
pin: { label: "Join at", showJoinUrl: true },
|
||||
text: { heading: "", body: "" },
|
||||
image: { imageUrl: "", fit: "cover" },
|
||||
agenda: { heading: "Agenda", items: [] },
|
||||
custom: { definitionId: "" }
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user