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.
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";
|
||||
Reference in New Issue
Block a user