A display tried each of its network paths once: - A registration that failed left it continuing from its persisted state, behind the offline banner, with no realtime, heartbeat or retry, until someone reloaded it. This is what the production kiosk showed after a restart. - A failed state refresh scheduled no further refresh, so a display whose realtime was also down never fetched state again. - A realtime stream the host reported closed was never subscribed again. Each path now retries on its own backoff: about 1 s, doubling to a one-minute ceiling, with jitter so a building's screens do not return in lockstep. There is no attempt limit, because a television has nobody to press Retry. Each resets when its request works again. - Registration retries until it works, then brings the display live exactly as a first-time success does. - A failed refresh schedules its own retry, with no realtime needed. - A closed realtime subscription is replaced. - Stopping clears every retry. The recovery test's virtual window shrinks from an hour to five minutes: eight failures back off for at most about 183 s, and an hour of offline-continuation timers made the test slow without proving more. Co-Authored-By: Claude Opus 5.5 <[email protected]>
751 lines
27 KiB
TypeScript
751 lines
27 KiB
TypeScript
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import { displayRetryDelayMs } from "../browser/backoff.js";
|
|
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";
|
|
import {
|
|
type DisplayChannelState,
|
|
type DisplayHeartbeat,
|
|
type DisplayRealtimeGroup,
|
|
parseViewerSessionResponse,
|
|
type ViewerSessionResponse,
|
|
} from "../shared/display-protocol.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;
|
|
|
|
/** The channel state a display renders, exactly as the server sends it (`src/shared`). */
|
|
export type DisplayChannelStateView = DisplayChannelState;
|
|
|
|
/** The display's registration, exactly as the server answers it (`src/shared`). */
|
|
export type DisplaySessionView = ViewerSessionResponse;
|
|
|
|
/** 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;
|
|
/** Retries, each on its own backoff. A display has nobody to press Retry, so none of them give up;
|
|
* each resets once its request works again. */
|
|
let sessionHandle: unknown;
|
|
let sessionAttempt = 0;
|
|
let refreshAttempt = 0;
|
|
let realtimeHandle: unknown;
|
|
let realtimeAttempt = 0;
|
|
/** 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) {
|
|
await refreshFailed();
|
|
return;
|
|
}
|
|
const state = (await response.json()) as DisplayChannelStateView;
|
|
refreshAttempt = 0;
|
|
await apply(state);
|
|
} catch {
|
|
await refreshFailed();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The server is unreachable, or answered with an error. The frame stays and local continuation
|
|
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
|
|
* again on its own, because a realtime message that would otherwise prompt it may never come.
|
|
*/
|
|
async function refreshFailed(): Promise<void> {
|
|
online = false;
|
|
await runOffline();
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (refreshHandle !== undefined) {
|
|
clearTimer(refreshHandle);
|
|
}
|
|
refreshHandle = setTimer(() => {
|
|
refreshHandle = undefined;
|
|
void refresh();
|
|
}, displayRetryDelayMs(refreshAttempt));
|
|
refreshAttempt += 1;
|
|
}
|
|
|
|
/**
|
|
* Registers this display, and keeps trying until it can.
|
|
*
|
|
* Until it has registered, a display continues from its own persisted state, honestly marked as
|
|
* offline. The first registration that works brings it live — realtime, heartbeats, and fresh
|
|
* state — exactly as if it had worked the first time.
|
|
*/
|
|
async function register(): Promise<void> {
|
|
if (stopped || session) {
|
|
return;
|
|
}
|
|
let registered: DisplaySessionView | undefined;
|
|
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) {
|
|
// Read, not cast: a registration the display cannot understand is no registration, and is
|
|
// tried again like any other failure.
|
|
const parsed = parseViewerSessionResponse(await response.json());
|
|
registered = parsed.ok ? parsed.value : undefined;
|
|
}
|
|
} catch {
|
|
registered = undefined;
|
|
}
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (!registered) {
|
|
online = false;
|
|
report();
|
|
sessionHandle = setTimer(() => {
|
|
sessionHandle = undefined;
|
|
void register();
|
|
}, displayRetryDelayMs(sessionAttempt));
|
|
sessionAttempt += 1;
|
|
return;
|
|
}
|
|
sessionAttempt = 0;
|
|
session = registered;
|
|
online = true;
|
|
subscribe();
|
|
heartbeatHandle = setInterval(
|
|
() => void heartbeat(),
|
|
registered.heartbeatIntervalMs,
|
|
) as unknown;
|
|
await refresh();
|
|
void heartbeat();
|
|
report();
|
|
}
|
|
|
|
/**
|
|
* Subscribes to the display's realtime channel, and again whenever the host reports that a
|
|
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
|
|
* is refused outright ends, and until this resubscribes the display would only learn of changes
|
|
* at its next slot deadline.
|
|
*/
|
|
function subscribe(): void {
|
|
if (stopped || !session) {
|
|
return;
|
|
}
|
|
subscription?.close();
|
|
subscription = options.context.realtime.subscribe("display-state", {
|
|
onSnapshot: (state) => onRealtime(state),
|
|
onStatus: (status) => {
|
|
online = status === "connected";
|
|
if (status === "connected") {
|
|
realtimeAttempt = 0;
|
|
} else if (status === "closed" && !stopped && realtimeHandle === undefined) {
|
|
realtimeHandle = setTimer(() => {
|
|
realtimeHandle = undefined;
|
|
subscribe();
|
|
}, displayRetryDelayMs(realtimeAttempt));
|
|
realtimeAttempt += 1;
|
|
}
|
|
report();
|
|
},
|
|
onUpdate: (state) => onRealtime(state),
|
|
});
|
|
}
|
|
|
|
async function heartbeat(): Promise<void> {
|
|
if (stopped || !session || !telemetry) {
|
|
return;
|
|
}
|
|
try {
|
|
// Built as the shared message type, which is the same type the route parses with.
|
|
const report: DisplayHeartbeat = {
|
|
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,
|
|
};
|
|
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
|
body: JSON.stringify(report),
|
|
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) {
|
|
await register();
|
|
}
|
|
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;
|
|
}
|
|
for (const handle of [sessionHandle, realtimeHandle]) {
|
|
if (handle !== undefined) {
|
|
clearTimer(handle);
|
|
}
|
|
}
|
|
sessionHandle = undefined;
|
|
realtimeHandle = 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;
|
|
}
|
|
// Checked at run time as well as typed: realtime state is the platform's transport, and a frame
|
|
// this display cannot read is simply not one it acts on.
|
|
const groups = (payload as { groups?: readonly Partial<DisplayRealtimeGroup>[] } | 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();
|
|
}
|
|
}
|