feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type OfflineSnapshot,
|
||||
resolveOffline,
|
||||
serverNowFrom,
|
||||
snapshotFrom,
|
||||
} from "../browser/offline.js";
|
||||
import { createSceneStage, type SceneStage } from "../browser/scene.js";
|
||||
import {
|
||||
nativeSceneRequestFor,
|
||||
type SceneSelection,
|
||||
sceneRequestFor,
|
||||
} from "../browser/scene-request.js";
|
||||
import {
|
||||
createBrowserMediaCache,
|
||||
createBrowserStore,
|
||||
type SignageLocalStore,
|
||||
type SignageMediaCache,
|
||||
signageMediaKey,
|
||||
} from "../browser/storage.js";
|
||||
|
||||
/**
|
||||
* The display: what it fetches, what it shows, and what it reports.
|
||||
*
|
||||
* The order of operations is the product. Server state arrives, the scene is prepared in the hidden
|
||||
* layer and *awaited*, and only then does the incoming layer fade in over an outgoing layer that was
|
||||
* never touched. When content cannot be prepared, the last good frame stays exactly where it is and
|
||||
* the failure becomes a bounded, sanitized code in the next heartbeat — never anything on screen.
|
||||
*
|
||||
* Everything the platform provides arrives through the host context: the API client, realtime, and
|
||||
* the renderers other modules contributed. Nothing here builds a URL, opens a socket, or reaches for
|
||||
* a credential.
|
||||
*/
|
||||
|
||||
const VIEWER_KEY_STORAGE = "viewer-key";
|
||||
const SNAPSHOT_STORAGE = "last-known-good";
|
||||
/** A safety-net refetch. Realtime drives every transition, but a display that missed one message
|
||||
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */
|
||||
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
|
||||
|
||||
export interface DisplayChannelStateView {
|
||||
readonly serverNowMs: number;
|
||||
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
|
||||
readonly crossfadeMs: number;
|
||||
readonly playback: {
|
||||
readonly revision: number;
|
||||
readonly transport: string;
|
||||
readonly blackout: boolean;
|
||||
readonly remainingMs: number | null;
|
||||
readonly selectionRevision: number | null;
|
||||
readonly currentSelectionId: string | null;
|
||||
readonly currentSlotId: string | null;
|
||||
readonly activeTimelineId: string | null;
|
||||
readonly pendingTimelineId: string | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
};
|
||||
readonly timeline: OfflineSnapshot["timeline"];
|
||||
readonly current: SceneSelection | null;
|
||||
readonly staticHold: SceneSelection | null;
|
||||
readonly selections: readonly SceneSelection[];
|
||||
}
|
||||
|
||||
export interface DisplaySessionView {
|
||||
readonly viewerId: string;
|
||||
readonly channelId: string;
|
||||
readonly channelKey: string;
|
||||
readonly groupId: string;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
}
|
||||
|
||||
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
|
||||
* construction: identity, counts, and a code. */
|
||||
export interface DisplayDiagnostics {
|
||||
readonly viewerId: string | undefined;
|
||||
readonly channelId: string | undefined;
|
||||
readonly renderedKey: string | undefined;
|
||||
readonly renderedSelectionId: string | undefined;
|
||||
readonly slate: boolean;
|
||||
readonly blackout: boolean;
|
||||
readonly online: boolean;
|
||||
/** Whether this display is currently continuing from its own persisted state rather than from the
|
||||
* server. Reported so an operator can see it; never sent back as authority. */
|
||||
readonly offline: boolean;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly unusableSelectionIds: readonly string[];
|
||||
readonly lastError: { readonly code: string; readonly detail: string | null } | undefined;
|
||||
}
|
||||
|
||||
export interface DisplayControllerOptions {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly root: Element;
|
||||
readonly now?: () => number;
|
||||
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
|
||||
readonly clearTimer?: (handle: unknown) => void;
|
||||
readonly store?: SignageLocalStore;
|
||||
readonly media?: SignageMediaCache;
|
||||
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
|
||||
readonly newViewerKey?: () => string;
|
||||
readonly onDiagnostics?: (diagnostics: DisplayDiagnostics) => void;
|
||||
/** Suppresses viewer registration and heartbeats. The management preview uses this: it renders
|
||||
* exactly what a wall renders, and must not appear in the display roster while doing so. */
|
||||
readonly telemetry?: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayController {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */
|
||||
apply(state: DisplayChannelStateView): Promise<void>;
|
||||
diagnostics(): DisplayDiagnostics;
|
||||
}
|
||||
|
||||
export function createDisplayController(options: DisplayControllerOptions): DisplayController {
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const setTimer =
|
||||
options.setTimer ??
|
||||
((handler: () => void, delayMs: number) => {
|
||||
return setTimeout(handler, delayMs);
|
||||
});
|
||||
const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never));
|
||||
const store = options.store ?? createBrowserStore();
|
||||
const media = options.media ?? createBrowserMediaCache();
|
||||
const telemetry = options.telemetry !== false;
|
||||
|
||||
let stage: SceneStage | undefined;
|
||||
let session: DisplaySessionView | undefined;
|
||||
let subscription: { close(): void } | undefined;
|
||||
let heartbeatHandle: unknown;
|
||||
let refreshHandle: unknown;
|
||||
let stopped = false;
|
||||
let online = false;
|
||||
let renderedSelectionId: string | undefined;
|
||||
let cachedAssetCount = 0;
|
||||
let lastError: { code: string; detail: string | null } | undefined;
|
||||
let appliedRevision = -1;
|
||||
let appliedSelectionRevision: number | null = null;
|
||||
let lastState: DisplayChannelStateView | undefined;
|
||||
let snapshot: OfflineSnapshot | undefined;
|
||||
let offlineActive = false;
|
||||
let offlineHandle: unknown;
|
||||
/** Selections this display has tried and failed to render. A broken item is skipped rather than
|
||||
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
|
||||
const unusable = new Set<string>();
|
||||
|
||||
function report(): void {
|
||||
// A quiet, honest state on the root, which the display renders as a small corner indicator. A
|
||||
// screen whose switch was rebooted must not blank a lobby wall to say so.
|
||||
(options.root as HTMLElement).dataset.offline = offlineActive ? "true" : "false";
|
||||
options.onDiagnostics?.(diagnostics());
|
||||
}
|
||||
|
||||
function diagnostics(): DisplayDiagnostics {
|
||||
return {
|
||||
blackout: lastState?.playback.blackout ?? snapshot?.playback.blackout ?? false,
|
||||
cachedAssetCount,
|
||||
channelId: session?.channelId,
|
||||
lastError,
|
||||
offline: offlineActive,
|
||||
offlineReady:
|
||||
snapshot !== undefined && cachedAssetCount >= 0 && snapshot.selections.length > 0,
|
||||
online,
|
||||
renderedKey: stage?.currentKey(),
|
||||
renderedSelectionId,
|
||||
slate: stage?.currentKey() === undefined,
|
||||
unusableSelectionIds: [...unusable],
|
||||
viewerId: session?.viewerId,
|
||||
};
|
||||
}
|
||||
|
||||
function recordError(code: string, detail: string | null = null): void {
|
||||
lastError = { code, detail };
|
||||
report();
|
||||
}
|
||||
|
||||
async function viewerKey(): Promise<string> {
|
||||
const existing = await store.read<string>(VIEWER_KEY_STORAGE);
|
||||
if (typeof existing === "string" && existing.length >= 8) {
|
||||
return existing;
|
||||
}
|
||||
// A display's identity is its own and persists across reboots, so a television that restarts is
|
||||
// recognized as the same screen rather than filling the roster with ghosts.
|
||||
const created =
|
||||
options.newViewerKey?.() ??
|
||||
`viewer-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||
await store.write(VIEWER_KEY_STORAGE, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Caches a native image's bytes under a **stable** key, and prefers the cached copy when it
|
||||
* exists. A delivery URL is signed and changes on every refresh; the bytes do not. */
|
||||
async function localUrlFor(selection: SceneSelection): Promise<string | undefined> {
|
||||
if (selection.renderer.kind !== "native-image") {
|
||||
return undefined;
|
||||
}
|
||||
const key = signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
});
|
||||
const cached = await media.find(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const remote = selection.media?.display.url;
|
||||
if (remote === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const stored = await media.store(key, remote);
|
||||
cachedAssetCount = await media.count();
|
||||
return stored ?? remote;
|
||||
}
|
||||
|
||||
async function renderSelection(
|
||||
selection: SceneSelection,
|
||||
state: DisplayChannelStateView,
|
||||
): Promise<void> {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
if (unusable.has(selection.id)) {
|
||||
// Already known broken here. Keep what is on screen rather than trying it again immediately.
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
audienceKey: state.channel.key,
|
||||
localNowMs: now(),
|
||||
paused: state.playback.transport === "paused",
|
||||
remainingMs: state.playback.remainingMs,
|
||||
serverNowMs: state.serverNowMs,
|
||||
};
|
||||
const request = sceneRequestFor(selection, context, {
|
||||
canMountContribution: (contributionId) =>
|
||||
options.context.contributions
|
||||
.list("signage.slide-renderer")
|
||||
.some((candidate) => candidate.id === contributionId),
|
||||
});
|
||||
if (!request) {
|
||||
// Not renderable here — a missing delivery URL, or a renderer the platform did not publish.
|
||||
// That is a fact about this display, not a failure of playback.
|
||||
unusable.add(selection.id);
|
||||
recordError("SLIDE_UNRENDERABLE");
|
||||
return;
|
||||
}
|
||||
if (request.kind === "native-image") {
|
||||
const url = await localUrlFor(selection);
|
||||
if (url !== undefined) {
|
||||
Object.assign(request, { url });
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = await stage.show(request);
|
||||
if (outcome.kind === "failed") {
|
||||
unusable.add(selection.id);
|
||||
recordError(outcome.code);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "superseded") {
|
||||
return;
|
||||
}
|
||||
renderedSelectionId = selection.id;
|
||||
if (lastError !== undefined) {
|
||||
lastError = undefined;
|
||||
}
|
||||
report();
|
||||
}
|
||||
|
||||
async function apply(state: DisplayChannelStateView): Promise<void> {
|
||||
if (stopped || !stage) {
|
||||
return;
|
||||
}
|
||||
// The server is authoritative the instant it speaks. Any local cursor is abandoned rather than
|
||||
// reconciled, and it was never reported anywhere to begin with.
|
||||
online = true;
|
||||
exitOffline();
|
||||
lastState = state;
|
||||
// The crossfade is an operator setting and arrives with the state, so the stage is told rather
|
||||
// than assuming one.
|
||||
stage.setCrossfade(state.crossfadeMs);
|
||||
appliedRevision = state.playback.revision;
|
||||
appliedSelectionRevision = state.playback.selectionRevision;
|
||||
// Blackout covers the output; the timeline underneath keeps running and keeps being prepared, so
|
||||
// ending it lands on whatever the server says is current rather than on a stale frame.
|
||||
stage.setBlackout(state.playback.blackout);
|
||||
|
||||
const target = state.staticHold ?? state.current;
|
||||
if (target === null) {
|
||||
// Nothing renderable from the server. If something good is already on screen it stays; only a
|
||||
// display with nothing at all shows the slate.
|
||||
if (stage.currentKey() === undefined) {
|
||||
stage.setSlate(true);
|
||||
}
|
||||
report();
|
||||
return;
|
||||
}
|
||||
stage.setSlate(false);
|
||||
await renderSelection(target, state);
|
||||
// Everything else the server sent is staged for later: a reconnect should have nothing left to
|
||||
// download, and an outage should have something to continue from. The snapshot is written only
|
||||
// once the frame is up, so what a display resumes from is what it actually showed — and the
|
||||
// media prune runs after it, so nothing is discarded until its replacement is usable.
|
||||
void prefetch(state);
|
||||
await persist(state);
|
||||
scheduleRefresh(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the last known good state.
|
||||
*
|
||||
* Written **after** the frame is on screen, so what a display resumes from is what it actually
|
||||
* showed. Delivery URLs are stripped on the way in: one is a bearer capability with an expiry in
|
||||
* it, and the bytes live in the media cache under a stable key instead.
|
||||
*/
|
||||
async function persist(state: DisplayChannelStateView): Promise<void> {
|
||||
snapshot = snapshotFrom({
|
||||
channel: state.channel,
|
||||
crossfadeMs: state.crossfadeMs,
|
||||
localNowMs: now(),
|
||||
playback: {
|
||||
blackout: state.playback.blackout,
|
||||
currentSelectionId: state.playback.currentSelectionId,
|
||||
currentSlotId: state.playback.currentSlotId,
|
||||
deadlineAtMs: state.playback.deadlineAtMs,
|
||||
pendingTimelineId: state.playback.pendingTimelineId,
|
||||
remainingMs: state.playback.remainingMs,
|
||||
revision: state.playback.revision,
|
||||
startedAtMs: state.playback.startedAtMs,
|
||||
transport: state.playback.transport,
|
||||
},
|
||||
selections: state.selections,
|
||||
serverNowMs: state.serverNowMs,
|
||||
staticHold: state.staticHold,
|
||||
timeline: state.timeline,
|
||||
});
|
||||
try {
|
||||
await store.write(SNAPSHOT_STORAGE, snapshot);
|
||||
} catch {
|
||||
// Persistence is best effort and never blocks rendering. A display that cannot write still
|
||||
// works — online.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues from the last authoritative anchor, with no network.
|
||||
*
|
||||
* Deterministic, arithmetic, and bounded: it replays selections the server already resolved and
|
||||
* this display already cached, and it never invents a provider's answer. Its cursor is never
|
||||
* reported anywhere — the moment the server speaks again, the server wins.
|
||||
*/
|
||||
async function runOffline(): Promise<void> {
|
||||
if (stopped || online || !stage || !snapshot) {
|
||||
return;
|
||||
}
|
||||
offlineActive = true;
|
||||
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
|
||||
// way its operator configured rather than the way this file happened to default.
|
||||
stage.setCrossfade(snapshot.crossfadeMs);
|
||||
const atMs = serverNowFrom(snapshot, now());
|
||||
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
||||
const resolution = evaluation.resolution;
|
||||
|
||||
if (resolution.kind === "empty") {
|
||||
// The cached timeline genuinely has nothing eligible. Black is correct, and only when nothing
|
||||
// good is already up.
|
||||
if (stage.currentKey() === undefined) {
|
||||
stage.setSlate(true);
|
||||
}
|
||||
} else if (resolution.kind === "static" || resolution.kind === "selection") {
|
||||
await renderOfflineSelection(resolution.selection, atMs);
|
||||
} else if (resolution.kind === "paused" && resolution.selection) {
|
||||
await renderOfflineSelection(resolution.selection, atMs);
|
||||
}
|
||||
// `hold` is deliberate: keep the last good frame and say nothing.
|
||||
|
||||
report();
|
||||
scheduleOffline(evaluation.nextBoundaryMs, atMs);
|
||||
}
|
||||
|
||||
async function renderOfflineSelection(selection: SceneSelection, atMs: number): Promise<void> {
|
||||
if (!stage || !snapshot) {
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
audienceKey: snapshot.channel.key,
|
||||
localNowMs: now(),
|
||||
paused: snapshot.playback.transport === "paused",
|
||||
remainingMs: snapshot.playback.remainingMs,
|
||||
serverNowMs: atMs,
|
||||
};
|
||||
|
||||
let request: ReturnType<typeof sceneRequestFor>;
|
||||
if (selection.renderer.kind === "native-image") {
|
||||
// Offline, only what is genuinely cached can be painted: a persisted selection carries no
|
||||
// delivery URL, deliberately, so the local copy is the only source there is.
|
||||
const cached = await media.find(
|
||||
signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
}),
|
||||
);
|
||||
request =
|
||||
cached === undefined ? undefined : nativeSceneRequestFor(selection, context, cached);
|
||||
} else {
|
||||
request = sceneRequestFor(selection, context, {
|
||||
canMountContribution: (contributionId) =>
|
||||
options.context.contributions
|
||||
.list("signage.slide-renderer")
|
||||
.some((candidate) => candidate.id === contributionId),
|
||||
});
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
// Nothing local can draw this. The slot is skipped and the last good frame stays.
|
||||
unusable.add(selection.id);
|
||||
return;
|
||||
}
|
||||
const outcome = await stage.show(request);
|
||||
if (outcome.kind === "failed") {
|
||||
unusable.add(selection.id);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind !== "superseded") {
|
||||
renderedSelectionId = selection.id;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleOffline(boundaryMs: number | null, atMs: number): void {
|
||||
if (offlineHandle !== undefined) {
|
||||
clearTimer(offlineHandle);
|
||||
offlineHandle = undefined;
|
||||
}
|
||||
if (stopped || online || boundaryMs === null) {
|
||||
return;
|
||||
}
|
||||
offlineHandle = setTimer(
|
||||
() => {
|
||||
offlineHandle = undefined;
|
||||
void runOffline();
|
||||
},
|
||||
Math.max(250, boundaryMs - atMs),
|
||||
);
|
||||
}
|
||||
|
||||
function exitOffline(): void {
|
||||
offlineActive = false;
|
||||
if (offlineHandle !== undefined) {
|
||||
clearTimer(offlineHandle);
|
||||
offlineHandle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function prefetch(state: DisplayChannelStateView): Promise<void> {
|
||||
for (const selection of state.selections) {
|
||||
if (selection.renderer.kind !== "native-image") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await localUrlFor(selection);
|
||||
} catch {
|
||||
// A slide that cannot be pre-cached is simply not pre-cached. It will be tried again when it
|
||||
// is actually needed, and the frame on screen is unaffected.
|
||||
}
|
||||
}
|
||||
// Superseded bytes are removed **only now**, after everything this state needs is present: a
|
||||
// prune that ran first could leave a screen with less than it had.
|
||||
await media.prune(
|
||||
state.selections
|
||||
.filter((selection) => selection.renderer.kind === "native-image")
|
||||
.map((selection) =>
|
||||
signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
}),
|
||||
),
|
||||
);
|
||||
cachedAssetCount = await media.count();
|
||||
report();
|
||||
}
|
||||
|
||||
function scheduleRefresh(state: DisplayChannelStateView): void {
|
||||
if (refreshHandle !== undefined) {
|
||||
clearTimer(refreshHandle);
|
||||
refreshHandle = undefined;
|
||||
}
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
// Just after the current slot's deadline, so a missed realtime message costs one late
|
||||
// transition rather than a stuck screen; and never longer than the safety-net interval.
|
||||
const untilDeadline =
|
||||
state.playback.deadlineAtMs === null
|
||||
? REFRESH_INTERVAL_MS
|
||||
: Math.max(250, state.playback.deadlineAtMs - state.serverNowMs + 250);
|
||||
refreshHandle = setTimer(
|
||||
() => {
|
||||
refreshHandle = undefined;
|
||||
void refresh();
|
||||
},
|
||||
Math.min(REFRESH_INTERVAL_MS, untilDeadline),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (stopped || !session) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await options.context.api.request(
|
||||
`channels/${session.channelId}/display-state`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
online = false;
|
||||
await runOffline();
|
||||
return;
|
||||
}
|
||||
await apply((await response.json()) as DisplayChannelStateView);
|
||||
} catch {
|
||||
// The server is unreachable. The frame stays and local continuation takes over; nothing about
|
||||
// a network failure belongs on a lobby wall.
|
||||
online = false;
|
||||
await runOffline();
|
||||
}
|
||||
}
|
||||
|
||||
async function heartbeat(): Promise<void> {
|
||||
if (stopped || !session || !telemetry) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
||||
body: JSON.stringify({
|
||||
cachedAssetCount,
|
||||
error:
|
||||
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
|
||||
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
|
||||
reportedContributionId:
|
||||
lastState?.current?.renderer.kind === "contribution"
|
||||
? lastState.current.renderer.contributionId
|
||||
: null,
|
||||
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
|
||||
reportedProviderId: lastState?.current?.providerId ?? null,
|
||||
reportedSelectionId: renderedSelectionId ?? null,
|
||||
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Telemetry is best effort and never affects what is on screen.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apply,
|
||||
diagnostics,
|
||||
|
||||
async start(): Promise<void> {
|
||||
stopped = false;
|
||||
stage = createSceneStage({
|
||||
crossfadeMs: 1000,
|
||||
...(options.decodeImage ? { decodeImage: options.decodeImage } : {}),
|
||||
mountContribution: async ({ container, contributionId, input }) => {
|
||||
// The platform loads, verifies, and mounts the renderer. Signage hands it identity and its
|
||||
// own opaque input, and receives a handle — never a URL, and never the module's code.
|
||||
const mounted = await options.context.contributions.mount({
|
||||
container,
|
||||
contributionId,
|
||||
input: input as never,
|
||||
});
|
||||
return {
|
||||
unmount: () => mounted.unmount(),
|
||||
update: (next: unknown) => mounted.update(next as never),
|
||||
};
|
||||
},
|
||||
root: options.root,
|
||||
setTimer,
|
||||
});
|
||||
|
||||
// A frame from the last known good state goes up before the first request returns, so a
|
||||
// reload during a service does not stare at black while a fetch completes.
|
||||
try {
|
||||
const restored = await store.read<OfflineSnapshot>(SNAPSHOT_STORAGE);
|
||||
if (restored?.version === 1) {
|
||||
snapshot = restored;
|
||||
await runOffline();
|
||||
}
|
||||
} catch {
|
||||
// Nothing persisted, or nothing readable. The display simply starts from the server.
|
||||
}
|
||||
|
||||
if (telemetry) {
|
||||
try {
|
||||
const response = await options.context.api.request("viewers/session", {
|
||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
session = (await response.json()) as DisplaySessionView;
|
||||
online = true;
|
||||
}
|
||||
} catch {
|
||||
online = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
subscription = options.context.realtime.subscribe("display-state", {
|
||||
onSnapshot: (state) => onRealtime(state),
|
||||
onStatus: (status) => {
|
||||
online = status === "connected";
|
||||
report();
|
||||
},
|
||||
onUpdate: (state) => onRealtime(state),
|
||||
});
|
||||
heartbeatHandle = setInterval(
|
||||
() => void heartbeat(),
|
||||
session.heartbeatIntervalMs,
|
||||
) as unknown;
|
||||
await refresh();
|
||||
void heartbeat();
|
||||
}
|
||||
report();
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
stopped = true;
|
||||
subscription?.close();
|
||||
subscription = undefined;
|
||||
if (heartbeatHandle !== undefined) {
|
||||
clearInterval(heartbeatHandle as never);
|
||||
heartbeatHandle = undefined;
|
||||
}
|
||||
if (refreshHandle !== undefined) {
|
||||
clearTimer(refreshHandle);
|
||||
refreshHandle = undefined;
|
||||
}
|
||||
exitOffline();
|
||||
await stage?.destroy();
|
||||
stage = undefined;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A realtime frame carries compact authoritative state and revisions, never content. A revision
|
||||
* this display has not applied means "fetch the state you are authorized for", which is what
|
||||
* closes the GET-then-subscribe race without a second transport (ADR 0005).
|
||||
*/
|
||||
function onRealtime(payload: unknown): void {
|
||||
if (stopped || !session) {
|
||||
return;
|
||||
}
|
||||
const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
|
||||
if (!Array.isArray(groups)) {
|
||||
return;
|
||||
}
|
||||
const mine = groups.find((group) => group.groupId === session?.groupId);
|
||||
if (!mine) {
|
||||
return;
|
||||
}
|
||||
online = true;
|
||||
const revision = typeof mine.revision === "number" ? mine.revision : -1;
|
||||
const selectionRevision =
|
||||
typeof mine.selectionRevision === "number" ? mine.selectionRevision : null;
|
||||
if (revision === appliedRevision && selectionRevision === appliedSelectionRevision) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user