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
+31
View File
@@ -0,0 +1,31 @@
import type { ReactElement, ReactNode } from "react";
/**
* The narrowest viewport the Signage management UI is designed for.
*
* Signage's dashboard is a timeline editor, a slot table, a live preview, and a display roster shown
* together. Compressing that into a phone would not make it usable, it would make it a different
* product — the mobile application this release deliberately does not build. So below this width the
* operator is told plainly where to go instead of being handed a broken layout.
*
* The switch is a CSS media query rather than a measured width in JavaScript: both branches are
* always in the DOM and always in the accessibility tree, so a screen reader and a test find the
* same content regardless of how the environment reports its viewport.
*/
export const SIGNAGE_MIN_DESKTOP_WIDTH_PX = 1024;
export function DesktopOnly({ children }: { readonly children: ReactNode }): ReactElement {
return (
<>
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
</p>
</div>
<div className="signage-desktop" data-testid="signage-desktop">
{children}
</div>
</>
);
}
+288
View File
@@ -0,0 +1,288 @@
import {
advanceCursor,
nextTimingBoundary,
type TimingSlot,
type TimingTimeline,
} from "../shared/playback-timing.js";
import type { SceneSelection } from "./scene-request.js";
/**
* Deterministic local continuation of the last authoritative state (ADR 0006).
*
* This is **resilience, never a second authority**. It reproduces the server's own documented
* semantics using the *same* arithmetic module the server reconciles with, so a screen that has been
* disconnected for an hour is showing what the server would have said, not what a second
* implementation guessed.
*
* Three rules bound what it may do:
*
* - **It never invents provider data.** It replays selections the server already resolved and this
* display already cached. A slot it has no cached answer for holds the last good frame rather than
* fabricating one.
* - **It never reports its own cursor back as authority.** Nothing here is sent anywhere.
* - **It catches up arithmetically.** A long outage costs one evaluation, not one per missed slide.
*/
export interface OfflineTimeline {
readonly id: string;
readonly revision: number;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly slots: readonly {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly estimatedDurationMs: number;
}[];
}
/** Everything a display persists so it can keep going. Structured, JSON-safe, and bounded. */
export interface OfflineSnapshot {
readonly version: 1;
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
readonly crossfadeMs: number;
readonly timeline: OfflineTimeline | null;
readonly playback: {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly pendingTimelineId: string | null;
};
readonly selections: readonly SceneSelection[];
readonly staticHold: SceneSelection | null;
/** The two clocks at the moment this was captured. A television's own clock is routinely wrong, so
* every later evaluation is done on the *server's* timeline, reconstructed from this pair. */
readonly anchor: { readonly serverNowMs: number; readonly localNowMs: number };
}
export type OfflineResolution =
/** Show this selection, with this window. */
| {
readonly kind: "selection";
readonly selection: SceneSelection;
readonly startedAtMs: number;
readonly deadlineAtMs: number;
}
/** The held static image. */
| { readonly kind: "static"; readonly selection: SceneSelection }
/** Frozen: whatever is on screen stays, with its remainder preserved. */
| { readonly kind: "paused"; readonly selection: SceneSelection | undefined }
/** Keep the last good frame. Something is genuinely unknown — a queued activation whose timeline
* this display never received, or a slot with no cached answer. Holding is the honest response. */
| { readonly kind: "hold"; readonly reason: string }
/** The cached timeline really has nothing eligible. Black is correct. */
| { readonly kind: "empty" };
/** The server's clock, reconstructed from the anchor. Correct however far off the device's is. */
export function serverNowFrom(snapshot: OfflineSnapshot, localNowMs: number): number {
return snapshot.anchor.serverNowMs + (localNowMs - snapshot.anchor.localNowMs);
}
function timingTimelineOf(timeline: OfflineTimeline): TimingTimeline {
const slots: TimingSlot[] = timeline.slots.map((slot) => ({
eligibleFromMs: slot.eligibleFromMs,
eligibleUntilMs: slot.eligibleUntilMs,
enabled: slot.enabled,
estimatedDurationMs: slot.estimatedDurationMs,
id: slot.id,
position: slot.position,
}));
return {
eligibleFromMs: timeline.eligibleFromMs,
eligibleUntilMs: timeline.eligibleUntilMs,
id: timeline.id,
slots,
};
}
/** Whether a cached selection may still be shown. Expired and explicitly withdrawn content is
* skipped; a null expiry is permanent, and local age never invents one. */
function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet<string>): boolean {
if (unusable.has(selection.id) || selection.withdrawn) {
return false;
}
return selection.availableUntilMs === null || atMs < selection.availableUntilMs;
}
/** The most recent cached answer for one slot. History is newest-first, so the first match is the
* one that most recently played there. */
function cachedForSlot(
snapshot: OfflineSnapshot,
slotId: string,
atMs: number,
unusable: ReadonlySet<string>,
): SceneSelection | undefined {
return snapshot.selections.find(
(selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable),
);
}
export interface OfflineEvaluation {
readonly resolution: OfflineResolution;
/** When to look again, or `null` when nothing is scheduled to change. */
readonly nextBoundaryMs: number | null;
}
/**
* Resolves what a disconnected display should be showing at `atMs`, on the server's clock.
*
* Pure and deterministic: the same snapshot and the same instant always give the same answer, which
* is what lets two screens that lost the same network stay on the same slide.
*/
export function resolveOffline(input: {
readonly snapshot: OfflineSnapshot;
readonly atMs: number;
readonly unusableSelectionIds?: ReadonlySet<string>;
}): OfflineEvaluation {
const { snapshot, atMs } = input;
const unusable = input.unusableSelectionIds ?? new Set<string>();
if (snapshot.staticHold !== null) {
// A hold is a deliberate operator decision, and local age never releases one. Only an explicit
// expiry on the held image does.
if (isUsable(snapshot.staticHold, atMs, unusable)) {
return {
nextBoundaryMs: null,
resolution: { kind: "static", selection: snapshot.staticHold },
};
}
}
if (snapshot.playback.transport === "paused") {
const current = snapshot.selections.find(
(selection) => selection.id === snapshot.playback.currentSelectionId,
);
// Frozen means frozen: the remainder is preserved and nothing advances.
return { nextBoundaryMs: null, resolution: { kind: "paused", selection: current } };
}
if (snapshot.playback.pendingTimelineId !== null) {
const deadline = snapshot.playback.deadlineAtMs;
if (deadline !== null && atMs >= deadline) {
// The next boundary belongs to a timeline this display never received. Going further into the
// old one would show known-wrong content, so the last good frame stays until the server can
// speak again.
return { nextBoundaryMs: null, resolution: { kind: "hold", reason: "pending-activation" } };
}
}
const timeline = snapshot.timeline;
if (!timeline || timeline.slots.length === 0) {
return { nextBoundaryMs: null, resolution: { kind: "empty" } };
}
const timing = timingTimelineOf(timeline);
let cursor = {
deadlineAtMs: snapshot.playback.deadlineAtMs,
slotId: snapshot.playback.currentSlotId,
startedAtMs: snapshot.playback.startedAtMs,
};
// Bounded by the number of slots: a slot with no cached answer is skipped exactly as the server
// skips a provider with nothing available, and one pass proves there is nothing to show.
const attempts = Math.max(1, timeline.slots.length);
for (let attempt = 0; attempt <= attempts; attempt += 1) {
const advance = advanceCursor(timing, cursor, atMs);
if (advance.kind === "empty") {
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "empty" },
};
}
if (advance.kind === "hold") {
const current = snapshot.selections.find(
(selection) => selection.id === snapshot.playback.currentSelectionId,
);
if (
current &&
cursor.startedAtMs !== null &&
cursor.deadlineAtMs !== null &&
isUsable(current, atMs, unusable)
) {
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: {
deadlineAtMs: cursor.deadlineAtMs,
kind: "selection",
selection: current,
startedAtMs: cursor.startedAtMs,
},
};
}
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "hold", reason: "no-cached-selection" },
};
}
const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable);
if (cached) {
const nextCursor = {
deadlineAtMs: advance.deadlineAtMs,
slotId: advance.slotId,
startedAtMs: advance.startedAtMs,
};
return {
nextBoundaryMs: nextTimingBoundary(timing, nextCursor, atMs),
resolution: {
deadlineAtMs: advance.deadlineAtMs,
kind: "selection",
selection: cached,
startedAtMs: advance.startedAtMs,
},
};
}
// No cached answer for this slot. Skip it exactly as the server skips an empty provider slot,
// and keep walking — inventing content would be worse than moving on.
cursor = {
deadlineAtMs: advance.startedAtMs,
slotId: advance.slotId,
startedAtMs: advance.startedAtMs,
};
}
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "hold", reason: "no-cached-selection" },
};
}
/** Builds the snapshot a display persists. Bounded and JSON-safe by construction. */
export function snapshotFrom(input: {
readonly channel: OfflineSnapshot["channel"];
readonly crossfadeMs: number;
readonly timeline: OfflineTimeline | null;
readonly playback: OfflineSnapshot["playback"];
readonly selections: readonly SceneSelection[];
readonly staticHold: SceneSelection | null;
readonly serverNowMs: number;
readonly localNowMs: number;
}): OfflineSnapshot {
return {
anchor: { localNowMs: input.localNowMs, serverNowMs: input.serverNowMs },
channel: input.channel,
crossfadeMs: input.crossfadeMs,
playback: input.playback,
// A delivery URL is a bearer capability with an expiry in it. Persisting one would store a
// credential that stops working; the bytes live in the media cache under a stable key instead.
selections: input.selections.map(withoutDeliveryUrls),
staticHold: input.staticHold === null ? null : withoutDeliveryUrls(input.staticHold),
timeline: input.timeline,
version: 1,
};
}
function withoutDeliveryUrls(selection: SceneSelection): SceneSelection {
const { media: _media, ...rest } = selection as SceneSelection & { media?: unknown };
return rest as SceneSelection;
}
+177
View File
@@ -0,0 +1,177 @@
import {
SIGNAGE_SLIDE_RENDERER_CONTRACT,
SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
type SignageSlideRendererInput,
signageRendererGeneration,
} from "../shared/renderer-input.js";
import type { SceneRequest } from "./scene.js";
/**
* Turning one resolved selection into something the stage can show.
*
* The one decision here worth naming is the **key**, and it is narrower than it first looks: a slide's
* identity is the selection and the audience, and *nothing else*. A payload revision, a pause, a
* re-anchored clock, and a fresh delivery signature are all things that happen to a slide already on
* screen — they become an `update`, which is the whole reason a cancelled event can change a
* mounted React tree instead of restarting its slot and every animation in it.
*
* The renderer still learns that something changed: `generation` inside its input *does* carry the
* revision and the paused flag, so a renderer keying internal state on it sees the change without
* the stage remounting anything.
*/
/** The subset of a channel state's selection this needs. Structural on purpose: the display and the
* management preview build the same request from the same shape. */
export interface SceneSelection {
readonly id: string;
readonly revision: number;
/** Which slot this occurrence came from. `null` for a static hold, and for history whose slot an
* operator has since deleted. */
readonly slotId: string | null;
/** When this selection stops being valid. `null` is permanent; local age never invents one. */
readonly availableUntilMs: number | null;
readonly withdrawn: boolean;
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly durationMs: number;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly payload: unknown;
readonly presentation: unknown;
readonly renderer:
| { readonly kind: "native-image" }
| {
readonly kind: "contribution";
readonly contributionId: string;
readonly contributionVersion: string;
}
| { readonly kind: "unavailable" };
readonly media?: {
readonly display: { readonly url: string };
readonly thumbnail: { readonly url: string };
};
readonly scene?: {
readonly fit: "fill" | "contain";
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
readonly backgroundColor: string;
readonly backgroundImage: boolean;
readonly blurPixels: number;
readonly darkenOpacity: number;
readonly shadow: boolean;
};
}
export interface SceneContext {
readonly audienceKey: string;
readonly paused: boolean;
readonly remainingMs: number | null;
/** The server's clock at the moment this state was received, and the device's at the same instant.
* A television's own clock is routinely wrong; this pair is what makes a renderer's countdown
* correct anyway. */
readonly serverNowMs: number;
readonly localNowMs: number;
}
export function sceneKeyFor(selection: SceneSelection, context: SceneContext): string {
return `${selection.id}:${context.audienceKey}`;
}
export function rendererInputFor(
selection: SceneSelection,
context: SceneContext,
): SignageSlideRendererInput {
return {
audienceKey: context.audienceKey,
clock: { localNowMs: context.localNowMs, serverNowMs: context.serverNowMs },
contract: SIGNAGE_SLIDE_RENDERER_CONTRACT,
generation: signageRendererGeneration({
audienceKey: context.audienceKey,
paused: context.paused,
revision: selection.revision,
selectionId: selection.id,
}),
itemId: selection.itemId,
itemRevision: selection.itemRevision,
payload: selection.payload,
presentation: selection.presentation,
providerId: selection.providerId,
timing: {
deadlineAtMs: selection.deadlineAtMs ?? context.serverNowMs + selection.durationMs,
durationMs: selection.durationMs,
paused: context.paused,
remainingMs: context.paused ? context.remainingMs : null,
startedAtMs: selection.startedAtMs ?? context.serverNowMs,
},
version: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
};
}
/**
* Builds a native-image scene from bytes this display already has.
*
* Separate from {@link sceneRequestFor} because offline is a different question: a persisted
* selection deliberately carries **no** delivery URL — one is a bearer capability with an expiry —
* so the only thing that can paint it is the local copy, and if there is none there is nothing to
* show.
*/
export function nativeSceneRequestFor(
selection: SceneSelection,
context: SceneContext,
localUrl: string,
): SceneRequest | undefined {
if (selection.renderer.kind !== "native-image" || selection.scene === undefined) {
return undefined;
}
return {
alt: "",
key: sceneKeyFor(selection, context),
kind: "native-image",
scene: selection.scene,
url: localUrl,
};
}
/**
* Builds a scene, or returns `undefined` when this selection cannot be shown here.
*
* `undefined` is not a failure: a native image whose delivery URL is missing, or a contributed
* renderer the platform did not publish, is content this display genuinely cannot render — and the
* caller keeps its last good frame rather than blanking the screen.
*/
export function sceneRequestFor(
selection: SceneSelection,
context: SceneContext,
options: { readonly canMountContribution: (contributionId: string) => boolean },
): SceneRequest | undefined {
const key = sceneKeyFor(selection, context);
if (selection.renderer.kind === "native-image") {
const url = selection.media?.display.url;
if (url === undefined || selection.scene === undefined) {
return undefined;
}
return {
alt: "",
key,
kind: "native-image",
scene: selection.scene,
url,
};
}
if (selection.renderer.kind !== "contribution") {
return undefined;
}
if (!options.canMountContribution(selection.renderer.contributionId)) {
// The contributing module is absent, disabled, or outside Signage's compatible range. That is
// ordinary rather than exceptional (ADR 0041) and the display simply cannot draw this item.
return undefined;
}
return {
contributionId: selection.renderer.contributionId,
input: rendererInputFor(selection, context),
key,
kind: "contribution",
};
}
+360
View File
@@ -0,0 +1,360 @@
import type { SignageNativeScene } from "../shared/native-images.js";
import type { SignageSlideRendererInput } from "../shared/renderer-input.js";
/**
* The two-layer scene stage (ADR 0006).
*
* The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
* transparent, which let the black page background show through the middle of every transition. That
* is the single most visible defect this file exists to prevent, and the rule that prevents it is
* one sentence: **the outgoing layer is never faded.**
*
* Two layers are created once and never rebuilt. For every transition the incoming content is
* prepared in the hidden layer and *actually awaited* — an image decode, or a contributed renderer's
* own `mount` promise — then raised above the outgoing layer at opacity zero and faded in. The
* outgoing layer stays fully opaque underneath until the fade completes.
*
* Everything the stage touches arrives as an option: the document, the clock, the timer, how an
* image is decoded, and how a contribution is mounted. That is what lets the transition itself be
* exercised rather than assumed, and what lets the management preview reuse this exact code without
* pretending to be a viewer.
*/
/** What a layer is asked to show. */
export type SceneRequest =
| {
readonly kind: "native-image";
/** Identity, so the stage can tell "the same slide, updated" from "a different slide". */
readonly key: string;
readonly url: string;
readonly alt: string;
readonly scene: SignageNativeScene;
}
| {
readonly kind: "contribution";
readonly key: string;
readonly contributionId: string;
readonly input: SignageSlideRendererInput;
};
/** What the stage needs of a mounted contribution. Structurally the platform's
* `MountedModuleContribution`, restated so this file imports no SDK and stays testable. */
export interface MountedRenderer {
update?(input: unknown): PromiseLike<void> | void;
unmount?(): PromiseLike<void> | void;
}
export interface SceneStageOptions {
readonly root: Element;
/** The starting crossfade. It is an operator setting that arrives with channel state, so it is
* also settable afterwards — a stage is created before the first state is fetched. */
readonly crossfadeMs: number;
/** Mounts one contributed renderer into a container the stage owns. */
readonly mountContribution?: (options: {
readonly container: Element;
readonly contributionId: string;
readonly input: SignageSlideRendererInput;
}) => Promise<MountedRenderer>;
/** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
/** How the crossfade is waited out. A test supplies a synchronous one. */
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
}
export type SceneOutcome =
/** The scene is now on screen. */
| { readonly kind: "shown" }
/** The scene was already on screen; only its input changed, and the renderer was updated in place
* without a transition. This is what lets a cancelled event change a slide without restarting it. */
| { readonly kind: "updated" }
/** A newer transition won while this one was preparing. Nothing changed. */
| { readonly kind: "superseded" }
/** The content could not be prepared. **The last good frame is still on screen.** */
| { readonly kind: "failed"; readonly code: string };
export interface SceneStage {
show(request: SceneRequest): Promise<SceneOutcome>;
/** Covers the output with pure black. The caller's timeline keeps running underneath. */
setBlackout(on: boolean): void;
/** The built-in broadcast slate. Shown only when there is nothing renderable *and* no good frame. */
setSlate(on: boolean): void;
/** What is on screen now, or `undefined` if nothing ever was. */
currentKey(): string | undefined;
setCrossfade(milliseconds: number): void;
destroy(): Promise<void>;
}
interface Layer {
readonly element: HTMLElement;
readonly background: HTMLElement;
readonly backgroundImage: HTMLImageElement;
readonly darken: HTMLElement;
readonly foreground: HTMLImageElement;
readonly rendererHost: HTMLElement;
key: string | undefined;
renderer: MountedRenderer | undefined;
}
function createLayer(document: Document, index: number): Layer {
const element = document.createElement("div");
element.className = "signage-layer";
element.dataset.layer = String(index);
element.style.opacity = "0";
const background = document.createElement("div");
background.className = "signage-layer__background";
const backgroundImage = document.createElement("img");
backgroundImage.className = "signage-layer__background-image";
backgroundImage.alt = "";
const darken = document.createElement("div");
darken.className = "signage-layer__darken";
const foreground = document.createElement("img");
foreground.className = "signage-layer__foreground";
const rendererHost = document.createElement("div");
rendererHost.className = "signage-layer__renderer";
element.append(background, backgroundImage, darken, foreground, rendererHost);
return {
background,
backgroundImage,
darken,
element,
foreground,
key: undefined,
renderer: undefined,
rendererHost,
};
}
export function createSceneStage(options: SceneStageOptions): SceneStage {
const document = options.root.ownerDocument;
if (!document) {
throw new Error("A Signage scene stage needs a document.");
}
const decodeImage =
options.decodeImage ??
((image: HTMLImageElement) =>
typeof image.decode === "function" ? image.decode() : Promise.resolve());
const setTimer =
options.setTimer ?? ((handler: () => void, delayMs: number) => setTimeout(handler, delayMs));
const layers: [Layer, Layer] = [createLayer(document, 0), createLayer(document, 1)];
// The stage owns the two layers and nothing else. Blackout and the slate are **states of the
// root**, reported as attributes: whoever owns this root renders them, so there is exactly one
// slate implementation and this file never duplicates it.
const root = options.root as HTMLElement;
root.dataset.blackout = "false";
root.dataset.slate = "false";
root.prepend(layers[1].element);
root.prepend(layers[0].element);
let currentIndex = 0;
let generation = 0;
let destroyed = false;
let crossfadeMs = options.crossfadeMs;
async function releaseRenderer(layer: Layer): Promise<void> {
const renderer = layer.renderer;
layer.renderer = undefined;
if (!renderer?.unmount) {
return;
}
try {
await renderer.unmount();
} catch {
// A renderer whose cleanup throws must not stop the stage. The layer is reused either way,
// and the DOM it owned is cleared below.
}
}
async function clearLayer(layer: Layer): Promise<void> {
await releaseRenderer(layer);
layer.foreground.removeAttribute("src");
layer.backgroundImage.removeAttribute("src");
layer.rendererHost.replaceChildren();
layer.key = undefined;
}
/**
* Prepares the hidden layer, refusing to leave anything behind for a generation that has already
* been superseded.
*
* The token is not a nicety: two transitions can be in flight at once — one waiting on a slow
* decode while a newer one arrives — and both target the same hidden layer. Without checking the
* token *after* every await, a slow mount resolving late would overwrite the newer transition's
* renderer handle and leak the renderer it replaced.
*/
async function prepare(layer: Layer, request: SceneRequest, token: number): Promise<void> {
await clearLayer(layer);
layer.element.dataset.sceneKey = request.key;
if (request.kind === "native-image") {
const scene = request.scene;
layer.element.dataset.fit = scene.fit;
layer.background.style.backgroundColor =
scene.backgroundMode === "solid" ? scene.backgroundColor : "#000000";
if (scene.backgroundImage) {
layer.backgroundImage.style.display = "block";
layer.backgroundImage.style.filter = `blur(${scene.blurPixels}px)`;
layer.backgroundImage.setAttribute("src", request.url);
} else {
layer.backgroundImage.style.display = "none";
}
layer.darken.style.opacity = String(scene.darkenOpacity);
layer.foreground.style.display = "block";
layer.foreground.style.objectFit = scene.fit === "fill" ? "cover" : "contain";
layer.foreground.style.filter = scene.shadow
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
: "none";
layer.foreground.alt = request.alt;
layer.foreground.setAttribute("src", request.url);
// The element that will actually be composited must be **decodable**, not merely present in
// the DOM. `onload` is not enough: a large image paints mid-fade.
await decodeImage(layer.foreground);
if (scene.backgroundImage) {
await decodeImage(layer.backgroundImage);
}
if (token !== generation) {
return;
}
layer.key = request.key;
return;
}
if (!options.mountContribution) {
throw new Error("This stage cannot mount contributed renderers.");
}
layer.foreground.removeAttribute("src");
layer.foreground.style.display = "none";
layer.backgroundImage.style.display = "none";
layer.darken.style.opacity = "0";
layer.background.style.backgroundColor = "#000000";
// Asynchronous by contract: the renderer resolves when it is genuinely ready to be seen, and the
// fade below does not begin until it does.
const mounted = await options.mountContribution({
container: layer.rendererHost,
contributionId: request.contributionId,
input: request.input,
});
if (token !== generation) {
// A newer transition owns this layer now. Release what was just mounted rather than storing a
// handle over the one that transition is using.
try {
await mounted.unmount?.();
} catch {
// Nothing to do: the layer belongs to someone else, and its own cleanup will run.
}
return;
}
layer.renderer = mounted;
layer.key = request.key;
}
function delay(ms: number): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
// Nothing is cleared here: a timer that has fired needs no cancelling, and clearing one from
// inside its own handler is what breaks when a caller supplies a synchronous timer.
return new Promise((resolve) => {
setTimer(resolve, ms);
});
}
return {
currentKey: () => layers[currentIndex]?.key,
async destroy(): Promise<void> {
destroyed = true;
generation += 1;
await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]);
layers[0].element.remove();
layers[1].element.remove();
},
setBlackout(on: boolean): void {
// Pure black, over everything, and nothing else changes. The timeline underneath keeps its
// deadlines and keeps resolving, so ending a blackout lands where it would have.
root.dataset.blackout = on ? "true" : "false";
},
setCrossfade(milliseconds: number): void {
crossfadeMs = Math.max(0, milliseconds);
},
setSlate(on: boolean): void {
root.dataset.slate = on ? "true" : "false";
},
async show(request: SceneRequest): Promise<SceneOutcome> {
if (destroyed) {
return { kind: "superseded" };
}
const outgoing = layers[currentIndex] as Layer;
// Already on screen. If it is a contributed renderer, hand it the new input in place: the
// whole point of `update` is a long-running screen changing content without restarting
// animations, reloading media, or resetting its slot.
if (outgoing.key === request.key) {
if (request.kind === "contribution" && outgoing.renderer?.update) {
try {
await outgoing.renderer.update(request.input);
return { kind: "updated" };
} catch {
return { code: "RENDERER_UPDATE_FAILED", kind: "failed" };
}
}
return { kind: "updated" };
}
generation += 1;
const token = generation;
const incomingIndex = currentIndex === 0 ? 1 : 0;
const incoming = layers[incomingIndex] as Layer;
try {
await prepare(incoming, request, token);
} catch {
// The last good frame stays exactly where it is. A broken slide costs one slide.
await clearLayer(incoming);
return {
code: request.kind === "native-image" ? "SLIDE_DECODE_FAILED" : "RENDERER_MOUNT_FAILED",
kind: "failed",
};
}
// Authoritative state may have moved on while this was preparing. Completion from a stale
// generation is discarded rather than shown — and deliberately without clearing the layer,
// which by now belongs to the newer transition.
if (destroyed || token !== generation) {
return { kind: "superseded" };
}
incoming.element.style.transitionDuration = "0ms";
incoming.element.style.opacity = "0";
incoming.element.style.zIndex = "2";
outgoing.element.style.zIndex = "1";
// Guarantees the browser observes opacity 0 before the transition starts, so the incoming
// layer animates instead of snapping.
void incoming.element.offsetHeight;
incoming.element.style.transitionDuration = `${Math.max(0, crossfadeMs)}ms`;
incoming.element.style.opacity = "1";
incoming.element.dataset.visible = "true";
// The outgoing layer is **not** touched. Fading both is what let the black root show through.
await delay(crossfadeMs);
if (destroyed || token !== generation) {
// A newer transition won while this one was fading; it owns the layers now.
return { kind: "superseded" };
}
currentIndex = incomingIndex;
outgoing.element.dataset.visible = "false";
outgoing.element.style.transitionDuration = "0ms";
outgoing.element.style.opacity = "0";
await clearLayer(outgoing);
return { kind: "shown" };
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactElement } from "react";
/**
* The built-in slate a Signage display shows when it has nothing it can honestly render: no timeline
* content, and no cached good frame to keep.
*
* It is deliberately unmistakable and deliberately uninformative. Restrained broadcast-style bars
* and four words tell anyone in the room that the screen is working and the content is not — while
* showing no exception, URL, provider payload, credential, database detail, or internal identifier.
* Full error detail belongs only in Signage's sanitized management telemetry, where an operator who
* is already authorized can read it.
*
* Nothing here animates. A lobby television showing a flashing diagnostic for an hour is worse than
* one showing a still frame, and an unattended screen is exactly the case this exists for.
*
* A deliberate blackout is **not** this: that renders pure black, and never reaches here.
*/
export function SignageSlate(): ReactElement {
return (
<section aria-live="polite" className="signage-slate" data-testid="signage-slate">
<div aria-hidden="true" className="signage-slate__bars">
<span className="signage-slate__bar signage-slate__bar--one" />
<span className="signage-slate__bar signage-slate__bar--two" />
<span className="signage-slate__bar signage-slate__bar--three" />
<span className="signage-slate__bar signage-slate__bar--four" />
<span className="signage-slate__bar signage-slate__bar--five" />
<span className="signage-slate__bar signage-slate__bar--six" />
</div>
<p className="signage-slate__message">Content unavailable</p>
</section>
);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* A display's own local storage: **IndexedDB for structured state, Cache Storage for image bytes**
* (ADR 0006).
*
* Ordinary HTTP cache is deliberately not used as the asset manager. It offers no eviction control,
* no way to prune a superseded rendition on purpose, and no way to answer the one question a display
* has to answer honestly — *what can I actually render if the network is gone?*
*
* Everything here is behind a small interface with an in-memory implementation, because a display's
* offline behaviour is the part that must be tested and IndexedDB is the part a test runner cannot
* reliably host. Both implementations satisfy the same contract; the browser one is thin.
*/
/** One key-value store of structured, JSON-serializable state. */
export interface SignageLocalStore {
read<Value>(key: string): Promise<Value | undefined>;
write(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}
/**
* Image bytes a display can still paint with no network.
*
* Everything is addressed by a **stable content key**, never by the delivery URL. A delivery URL is
* signed and short-lived: it changes on every refresh of the same bytes, so a cache keyed on one
* would store the same image over and over and find none of it after a reconnect. The key is derived
* from identity that does not change — the provider, the item, and the item's revision — so the
* bytes a display cached an hour ago are the bytes it can still paint now.
*/
export interface SignageMediaCache {
/** Fetches from `url` and stores under `key`, returning a local URL to render from. */
store(key: string, url: string): Promise<string | undefined>;
/** A local URL for previously stored bytes, or `undefined` if they were never stored. */
find(key: string): Promise<string | undefined>;
/** Removes every key not in the keep set. Called only after a new snapshot is usable, so a prune
* can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>;
count(): Promise<number>;
}
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never
* from a URL. */
export function signageMediaKey(input: {
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly rendition: string;
}): string {
return `signage-media/${encodeURIComponent(input.providerId)}/${encodeURIComponent(input.itemId)}/${encodeURIComponent(input.itemRevision)}/${input.rendition}`;
}
export const SIGNAGE_LOCAL_DATABASE = "ekklesia-signage";
export const SIGNAGE_LOCAL_STORE = "state";
export const SIGNAGE_MEDIA_CACHE = "ekklesia-signage-media-v1";
export function createMemoryStore(): SignageLocalStore {
const values = new Map<string, unknown>();
return {
delete(key: string): Promise<void> {
values.delete(key);
return Promise.resolve();
},
read<Value>(key: string): Promise<Value | undefined> {
return Promise.resolve(values.get(key) as Value | undefined);
},
write(key: string, value: unknown): Promise<void> {
// Structured state only. Round-tripping through JSON here is what keeps a `Date`, a function,
// or a cycle from being written now and failing to come back later.
values.set(key, JSON.parse(JSON.stringify(value)) as unknown);
return Promise.resolve();
},
};
}
export function createMemoryMediaCache(): SignageMediaCache {
const stored = new Map<string, string>();
return {
count: () => Promise.resolve(stored.size),
find: (key: string) => Promise.resolve(stored.get(key)),
prune(keep: readonly string[]): Promise<void> {
const retained = new Set(keep);
for (const key of [...stored.keys()]) {
if (!retained.has(key)) {
stored.delete(key);
}
}
return Promise.resolve();
},
store(key: string, url: string): Promise<string | undefined> {
stored.set(key, url);
return Promise.resolve(url);
},
};
}
/** The IndexedDB-backed store a real display uses. Falls back to memory where IndexedDB is absent —
* a private window, a policy that blocks storage — because a display that cannot persist must still
* render, online. */
export function createBrowserStore(scope: typeof globalThis = globalThis): SignageLocalStore {
const maybeIndexedDb = (scope as { indexedDB?: IDBFactory }).indexedDB;
if (!maybeIndexedDb) {
return createMemoryStore();
}
const indexedDb: IDBFactory = maybeIndexedDb;
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDb.open(SIGNAGE_LOCAL_DATABASE, 1);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(SIGNAGE_LOCAL_STORE)) {
database.createObjectStore(SIGNAGE_LOCAL_STORE);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("Signage could not open its local store."));
});
}
async function transact<Result>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest,
): Promise<Result | undefined> {
let database: IDBDatabase;
try {
database = await open();
} catch {
return undefined;
}
return new Promise<Result | undefined>((resolve) => {
try {
const transaction = database.transaction(SIGNAGE_LOCAL_STORE, mode);
const request = run(transaction.objectStore(SIGNAGE_LOCAL_STORE));
request.onsuccess = () => resolve(request.result as Result | undefined);
request.onerror = () => resolve(undefined);
transaction.oncomplete = () => database.close();
} catch {
resolve(undefined);
}
});
}
return {
async delete(key: string): Promise<void> {
await transact("readwrite", (store) => store.delete(key));
},
read<Value>(key: string): Promise<Value | undefined> {
return transact<Value>("readonly", (store) => store.get(key));
},
async write(key: string, value: unknown): Promise<void> {
await transact("readwrite", (store) => store.put(value, key));
},
};
}
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure
* origin in development, a policy that refuses — a display works exactly as it did before: online. */
export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
const maybeCaches = (scope as { caches?: CacheStorage }).caches;
const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
if (!maybeCaches || !fetchFn) {
return createMemoryMediaCache();
}
const caches: CacheStorage = maybeCaches;
async function open(): Promise<Cache | undefined> {
try {
return await caches.open(SIGNAGE_MEDIA_CACHE);
} catch {
return undefined;
}
}
/** Cache Storage keys on a request URL, so a stable key becomes a stable absolute URL on this
* origin. It is never fetched; it exists only to be a key. */
function cacheRequest(key: string): string {
const origin =
(scope as { location?: { origin?: string } }).location?.origin ?? "https://signage.invalid";
return `${origin}/${key}`;
}
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
const response = await cache.match(url);
if (!response) {
return undefined;
}
try {
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch {
return undefined;
}
}
return {
async count(): Promise<number> {
const cache = await open();
if (!cache) {
return 0;
}
try {
return (await cache.keys()).length;
} catch {
return 0;
}
},
async find(key: string): Promise<string | undefined> {
const cache = await open();
return cache ? localUrl(cache, cacheRequest(key)) : undefined;
},
async prune(keep: readonly string[]): Promise<void> {
const cache = await open();
if (!cache) {
return;
}
const retained = new Set(keep.map((key) => cacheRequest(key)));
try {
for (const request of await cache.keys()) {
if (!retained.has(request.url)) {
await cache.delete(request);
}
}
} catch {
// A prune that fails leaves a screen with more than it needs, which is the safe direction.
}
},
async store(key: string, url: string): Promise<string | undefined> {
const cache = await open();
if (!cache) {
return undefined;
}
try {
const response = await fetchFn(url);
// Only a complete, successful response is stored. A `404` or a redirect replayed later as a
// slide would be worse than having nothing cached.
if (!response.ok || response.status !== 200) {
return undefined;
}
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
// bytes instead of downloading them again.
await cache.put(cacheRequest(key), response.clone());
return localUrl(cache, cacheRequest(key));
} catch {
return undefined;
}
},
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* The upload queue behind the image library.
*
* The shape of it is the whole point: **one image, one request**, several at a time but never all at
* once. Twenty slides an operator dropped on the page become twenty individually bounded requests,
* each of which the server can refuse cheaply, rather than one enormous one whose failure wastes
* every byte already sent (ADR 0007). And because only a few are in flight, the dashboard stays
* responsive and the API's memory stays bounded while a batch runs.
*
* Pure and injectable: the queue takes a function that uploads one file and knows nothing about HTTP,
* so its ordering, concurrency, and failure behaviour are exercised directly.
*/
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "rejected";
export interface UploadItem {
readonly id: string;
readonly name: string;
readonly size: number;
readonly status: UploadStatus;
/** A sanitized code from the server, or one the queue itself decided before sending. */
readonly code?: string;
readonly message?: string;
}
export interface UploadFile {
readonly name: string;
readonly size: number;
readonly type: string;
slice?(start?: number, end?: number): Blob;
}
export interface UploadQueueOptions {
readonly concurrency: number;
readonly maxBytes: number;
readonly maxFiles: number;
/** Uploads exactly one file. Rejecting is a failure for that file and nothing else. */
readonly upload: (file: UploadFile) => Promise<void>;
readonly onChange: (items: readonly UploadItem[]) => void;
readonly newId?: () => string;
}
export interface UploadQueue {
/** Adds files and starts working. Returns immediately; progress arrives through `onChange`. */
enqueue(files: readonly UploadFile[]): void;
items(): readonly UploadItem[];
/** Resolves when nothing is left in flight. */
drain(): Promise<void>;
clearFinished(): void;
}
/** Formats the two rejections the queue itself makes, so an operator is told before a byte is sent
* rather than after the server refuses. */
export const UPLOAD_REJECTIONS = Object.freeze({
SIGNAGE_IMAGE_TOO_LARGE: "This file is larger than Signage accepts.",
SIGNAGE_UPLOAD_BATCH_TOO_LARGE: "Too many files at once. Add them in smaller batches.",
SIGNAGE_UPLOAD_UNSUPPORTED_TYPE: "Signage accepts PNG, JPEG, and WebP images.",
});
const ACCEPTED_TYPES: ReadonlySet<string> = new Set(["image/jpeg", "image/png", "image/webp"]);
export function createUploadQueue(options: UploadQueueOptions): UploadQueue {
const newId = options.newId ?? (() => Math.random().toString(36).slice(2));
let items: UploadItem[] = [];
const pending: { item: UploadItem; file: UploadFile }[] = [];
let active = 0;
let idle: Promise<void> = Promise.resolve();
let settleIdle: (() => void) | undefined;
function publish(): void {
options.onChange([...items]);
}
function replace(id: string, changes: Partial<UploadItem>): void {
items = items.map((item) => (item.id === id ? { ...item, ...changes } : item));
publish();
}
function pump(): void {
while (active < options.concurrency && pending.length > 0) {
const next = pending.shift();
if (!next) {
break;
}
active += 1;
replace(next.item.id, { status: "uploading" });
void options
.upload(next.file)
.then(() => replace(next.item.id, { status: "done" }))
.catch((error: unknown) => {
// One bad file fails alone. The rest of the batch keeps going, which is the entire reason
// a batch is many requests rather than one.
const failure = error as { failure?: { code?: string; message?: string } };
replace(next.item.id, {
code: failure.failure?.code ?? "SIGNAGE_REQUEST_FAILED",
message: failure.failure?.message ?? "Signage could not be reached.",
status: "failed",
});
})
.finally(() => {
active -= 1;
if (pending.length > 0) {
pump();
} else if (active === 0) {
settleIdle?.();
settleIdle = undefined;
}
});
}
}
return {
clearFinished(): void {
items = items.filter((item) => item.status === "queued" || item.status === "uploading");
publish();
},
drain(): Promise<void> {
if (active === 0 && pending.length === 0) {
return Promise.resolve();
}
idle = new Promise<void>((resolve) => {
settleIdle = resolve;
});
return idle;
},
enqueue(files: readonly UploadFile[]): void {
const accepted = files.slice(0, options.maxFiles);
const overflow = files.slice(options.maxFiles);
for (const file of accepted) {
const item: UploadItem = {
id: newId(),
name: file.name,
size: file.size,
status: "queued",
};
items = [...items, item];
// Two rejections happen here, before anything is sent: a file too large for Signage, and a
// type it does not accept. The server checks both again — it must, because a browser is not
// a security boundary — but telling an operator now costs them nothing.
if (file.size > options.maxBytes) {
items = items.map((candidate) =>
candidate.id === item.id
? {
...candidate,
code: "SIGNAGE_IMAGE_TOO_LARGE",
message: UPLOAD_REJECTIONS.SIGNAGE_IMAGE_TOO_LARGE,
status: "rejected",
}
: candidate,
);
continue;
}
if (file.type !== "" && !ACCEPTED_TYPES.has(file.type)) {
items = items.map((candidate) =>
candidate.id === item.id
? {
...candidate,
code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE",
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_UNSUPPORTED_TYPE,
status: "rejected",
}
: candidate,
);
continue;
}
pending.push({ file, item });
}
for (const file of overflow) {
items = [
...items,
{
code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE",
id: newId(),
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_BATCH_TOO_LARGE,
name: file.name,
size: file.size,
status: "rejected",
},
];
}
publish();
pump();
},
items: () => [...items],
};
}