feat: initial Signage 0.1.0
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user