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; 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; stop(): Promise; /** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */ apply(state: DisplayChannelStateView): Promise; 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(); 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 { const existing = await store.read(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 { 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 { 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 { 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 { 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 { 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 { 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; 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 { 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 { 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 { 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 { 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 { 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 { 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(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 { 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[] } | 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(); } }