feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+676
View File
@@ -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();
}
}