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],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type OfflineSnapshot,
|
||||
resolveOffline,
|
||||
serverNowFrom,
|
||||
snapshotFrom,
|
||||
} from "../browser/offline.js";
|
||||
import { createSceneStage, type SceneStage } from "../browser/scene.js";
|
||||
import {
|
||||
nativeSceneRequestFor,
|
||||
type SceneSelection,
|
||||
sceneRequestFor,
|
||||
} from "../browser/scene-request.js";
|
||||
import {
|
||||
createBrowserMediaCache,
|
||||
createBrowserStore,
|
||||
type SignageLocalStore,
|
||||
type SignageMediaCache,
|
||||
signageMediaKey,
|
||||
} from "../browser/storage.js";
|
||||
|
||||
/**
|
||||
* The display: what it fetches, what it shows, and what it reports.
|
||||
*
|
||||
* The order of operations is the product. Server state arrives, the scene is prepared in the hidden
|
||||
* layer and *awaited*, and only then does the incoming layer fade in over an outgoing layer that was
|
||||
* never touched. When content cannot be prepared, the last good frame stays exactly where it is and
|
||||
* the failure becomes a bounded, sanitized code in the next heartbeat — never anything on screen.
|
||||
*
|
||||
* Everything the platform provides arrives through the host context: the API client, realtime, and
|
||||
* the renderers other modules contributed. Nothing here builds a URL, opens a socket, or reaches for
|
||||
* a credential.
|
||||
*/
|
||||
|
||||
const VIEWER_KEY_STORAGE = "viewer-key";
|
||||
const SNAPSHOT_STORAGE = "last-known-good";
|
||||
/** A safety-net refetch. Realtime drives every transition, but a display that missed one message
|
||||
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */
|
||||
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
|
||||
|
||||
export interface DisplayChannelStateView {
|
||||
readonly serverNowMs: number;
|
||||
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
|
||||
readonly crossfadeMs: number;
|
||||
readonly playback: {
|
||||
readonly revision: number;
|
||||
readonly transport: string;
|
||||
readonly blackout: boolean;
|
||||
readonly remainingMs: number | null;
|
||||
readonly selectionRevision: number | null;
|
||||
readonly currentSelectionId: string | null;
|
||||
readonly currentSlotId: string | null;
|
||||
readonly activeTimelineId: string | null;
|
||||
readonly pendingTimelineId: string | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
};
|
||||
readonly timeline: OfflineSnapshot["timeline"];
|
||||
readonly current: SceneSelection | null;
|
||||
readonly staticHold: SceneSelection | null;
|
||||
readonly selections: readonly SceneSelection[];
|
||||
}
|
||||
|
||||
export interface DisplaySessionView {
|
||||
readonly viewerId: string;
|
||||
readonly channelId: string;
|
||||
readonly channelKey: string;
|
||||
readonly groupId: string;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
}
|
||||
|
||||
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
|
||||
* construction: identity, counts, and a code. */
|
||||
export interface DisplayDiagnostics {
|
||||
readonly viewerId: string | undefined;
|
||||
readonly channelId: string | undefined;
|
||||
readonly renderedKey: string | undefined;
|
||||
readonly renderedSelectionId: string | undefined;
|
||||
readonly slate: boolean;
|
||||
readonly blackout: boolean;
|
||||
readonly online: boolean;
|
||||
/** Whether this display is currently continuing from its own persisted state rather than from the
|
||||
* server. Reported so an operator can see it; never sent back as authority. */
|
||||
readonly offline: boolean;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly unusableSelectionIds: readonly string[];
|
||||
readonly lastError: { readonly code: string; readonly detail: string | null } | undefined;
|
||||
}
|
||||
|
||||
export interface DisplayControllerOptions {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly root: Element;
|
||||
readonly now?: () => number;
|
||||
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
|
||||
readonly clearTimer?: (handle: unknown) => void;
|
||||
readonly store?: SignageLocalStore;
|
||||
readonly media?: SignageMediaCache;
|
||||
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
|
||||
readonly newViewerKey?: () => string;
|
||||
readonly onDiagnostics?: (diagnostics: DisplayDiagnostics) => void;
|
||||
/** Suppresses viewer registration and heartbeats. The management preview uses this: it renders
|
||||
* exactly what a wall renders, and must not appear in the display roster while doing so. */
|
||||
readonly telemetry?: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayController {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */
|
||||
apply(state: DisplayChannelStateView): Promise<void>;
|
||||
diagnostics(): DisplayDiagnostics;
|
||||
}
|
||||
|
||||
export function createDisplayController(options: DisplayControllerOptions): DisplayController {
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const setTimer =
|
||||
options.setTimer ??
|
||||
((handler: () => void, delayMs: number) => {
|
||||
return setTimeout(handler, delayMs);
|
||||
});
|
||||
const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never));
|
||||
const store = options.store ?? createBrowserStore();
|
||||
const media = options.media ?? createBrowserMediaCache();
|
||||
const telemetry = options.telemetry !== false;
|
||||
|
||||
let stage: SceneStage | undefined;
|
||||
let session: DisplaySessionView | undefined;
|
||||
let subscription: { close(): void } | undefined;
|
||||
let heartbeatHandle: unknown;
|
||||
let refreshHandle: unknown;
|
||||
let stopped = false;
|
||||
let online = false;
|
||||
let renderedSelectionId: string | undefined;
|
||||
let cachedAssetCount = 0;
|
||||
let lastError: { code: string; detail: string | null } | undefined;
|
||||
let appliedRevision = -1;
|
||||
let appliedSelectionRevision: number | null = null;
|
||||
let lastState: DisplayChannelStateView | undefined;
|
||||
let snapshot: OfflineSnapshot | undefined;
|
||||
let offlineActive = false;
|
||||
let offlineHandle: unknown;
|
||||
/** Selections this display has tried and failed to render. A broken item is skipped rather than
|
||||
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
|
||||
const unusable = new Set<string>();
|
||||
|
||||
function report(): void {
|
||||
// A quiet, honest state on the root, which the display renders as a small corner indicator. A
|
||||
// screen whose switch was rebooted must not blank a lobby wall to say so.
|
||||
(options.root as HTMLElement).dataset.offline = offlineActive ? "true" : "false";
|
||||
options.onDiagnostics?.(diagnostics());
|
||||
}
|
||||
|
||||
function diagnostics(): DisplayDiagnostics {
|
||||
return {
|
||||
blackout: lastState?.playback.blackout ?? snapshot?.playback.blackout ?? false,
|
||||
cachedAssetCount,
|
||||
channelId: session?.channelId,
|
||||
lastError,
|
||||
offline: offlineActive,
|
||||
offlineReady:
|
||||
snapshot !== undefined && cachedAssetCount >= 0 && snapshot.selections.length > 0,
|
||||
online,
|
||||
renderedKey: stage?.currentKey(),
|
||||
renderedSelectionId,
|
||||
slate: stage?.currentKey() === undefined,
|
||||
unusableSelectionIds: [...unusable],
|
||||
viewerId: session?.viewerId,
|
||||
};
|
||||
}
|
||||
|
||||
function recordError(code: string, detail: string | null = null): void {
|
||||
lastError = { code, detail };
|
||||
report();
|
||||
}
|
||||
|
||||
async function viewerKey(): Promise<string> {
|
||||
const existing = await store.read<string>(VIEWER_KEY_STORAGE);
|
||||
if (typeof existing === "string" && existing.length >= 8) {
|
||||
return existing;
|
||||
}
|
||||
// A display's identity is its own and persists across reboots, so a television that restarts is
|
||||
// recognized as the same screen rather than filling the roster with ghosts.
|
||||
const created =
|
||||
options.newViewerKey?.() ??
|
||||
`viewer-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||
await store.write(VIEWER_KEY_STORAGE, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Caches a native image's bytes under a **stable** key, and prefers the cached copy when it
|
||||
* exists. A delivery URL is signed and changes on every refresh; the bytes do not. */
|
||||
async function localUrlFor(selection: SceneSelection): Promise<string | undefined> {
|
||||
if (selection.renderer.kind !== "native-image") {
|
||||
return undefined;
|
||||
}
|
||||
const key = signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
});
|
||||
const cached = await media.find(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const remote = selection.media?.display.url;
|
||||
if (remote === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const stored = await media.store(key, remote);
|
||||
cachedAssetCount = await media.count();
|
||||
return stored ?? remote;
|
||||
}
|
||||
|
||||
async function renderSelection(
|
||||
selection: SceneSelection,
|
||||
state: DisplayChannelStateView,
|
||||
): Promise<void> {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
if (unusable.has(selection.id)) {
|
||||
// Already known broken here. Keep what is on screen rather than trying it again immediately.
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
audienceKey: state.channel.key,
|
||||
localNowMs: now(),
|
||||
paused: state.playback.transport === "paused",
|
||||
remainingMs: state.playback.remainingMs,
|
||||
serverNowMs: state.serverNowMs,
|
||||
};
|
||||
const request = sceneRequestFor(selection, context, {
|
||||
canMountContribution: (contributionId) =>
|
||||
options.context.contributions
|
||||
.list("signage.slide-renderer")
|
||||
.some((candidate) => candidate.id === contributionId),
|
||||
});
|
||||
if (!request) {
|
||||
// Not renderable here — a missing delivery URL, or a renderer the platform did not publish.
|
||||
// That is a fact about this display, not a failure of playback.
|
||||
unusable.add(selection.id);
|
||||
recordError("SLIDE_UNRENDERABLE");
|
||||
return;
|
||||
}
|
||||
if (request.kind === "native-image") {
|
||||
const url = await localUrlFor(selection);
|
||||
if (url !== undefined) {
|
||||
Object.assign(request, { url });
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = await stage.show(request);
|
||||
if (outcome.kind === "failed") {
|
||||
unusable.add(selection.id);
|
||||
recordError(outcome.code);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "superseded") {
|
||||
return;
|
||||
}
|
||||
renderedSelectionId = selection.id;
|
||||
if (lastError !== undefined) {
|
||||
lastError = undefined;
|
||||
}
|
||||
report();
|
||||
}
|
||||
|
||||
async function apply(state: DisplayChannelStateView): Promise<void> {
|
||||
if (stopped || !stage) {
|
||||
return;
|
||||
}
|
||||
// The server is authoritative the instant it speaks. Any local cursor is abandoned rather than
|
||||
// reconciled, and it was never reported anywhere to begin with.
|
||||
online = true;
|
||||
exitOffline();
|
||||
lastState = state;
|
||||
// The crossfade is an operator setting and arrives with the state, so the stage is told rather
|
||||
// than assuming one.
|
||||
stage.setCrossfade(state.crossfadeMs);
|
||||
appliedRevision = state.playback.revision;
|
||||
appliedSelectionRevision = state.playback.selectionRevision;
|
||||
// Blackout covers the output; the timeline underneath keeps running and keeps being prepared, so
|
||||
// ending it lands on whatever the server says is current rather than on a stale frame.
|
||||
stage.setBlackout(state.playback.blackout);
|
||||
|
||||
const target = state.staticHold ?? state.current;
|
||||
if (target === null) {
|
||||
// Nothing renderable from the server. If something good is already on screen it stays; only a
|
||||
// display with nothing at all shows the slate.
|
||||
if (stage.currentKey() === undefined) {
|
||||
stage.setSlate(true);
|
||||
}
|
||||
report();
|
||||
return;
|
||||
}
|
||||
stage.setSlate(false);
|
||||
await renderSelection(target, state);
|
||||
// Everything else the server sent is staged for later: a reconnect should have nothing left to
|
||||
// download, and an outage should have something to continue from. The snapshot is written only
|
||||
// once the frame is up, so what a display resumes from is what it actually showed — and the
|
||||
// media prune runs after it, so nothing is discarded until its replacement is usable.
|
||||
void prefetch(state);
|
||||
await persist(state);
|
||||
scheduleRefresh(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the last known good state.
|
||||
*
|
||||
* Written **after** the frame is on screen, so what a display resumes from is what it actually
|
||||
* showed. Delivery URLs are stripped on the way in: one is a bearer capability with an expiry in
|
||||
* it, and the bytes live in the media cache under a stable key instead.
|
||||
*/
|
||||
async function persist(state: DisplayChannelStateView): Promise<void> {
|
||||
snapshot = snapshotFrom({
|
||||
channel: state.channel,
|
||||
crossfadeMs: state.crossfadeMs,
|
||||
localNowMs: now(),
|
||||
playback: {
|
||||
blackout: state.playback.blackout,
|
||||
currentSelectionId: state.playback.currentSelectionId,
|
||||
currentSlotId: state.playback.currentSlotId,
|
||||
deadlineAtMs: state.playback.deadlineAtMs,
|
||||
pendingTimelineId: state.playback.pendingTimelineId,
|
||||
remainingMs: state.playback.remainingMs,
|
||||
revision: state.playback.revision,
|
||||
startedAtMs: state.playback.startedAtMs,
|
||||
transport: state.playback.transport,
|
||||
},
|
||||
selections: state.selections,
|
||||
serverNowMs: state.serverNowMs,
|
||||
staticHold: state.staticHold,
|
||||
timeline: state.timeline,
|
||||
});
|
||||
try {
|
||||
await store.write(SNAPSHOT_STORAGE, snapshot);
|
||||
} catch {
|
||||
// Persistence is best effort and never blocks rendering. A display that cannot write still
|
||||
// works — online.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues from the last authoritative anchor, with no network.
|
||||
*
|
||||
* Deterministic, arithmetic, and bounded: it replays selections the server already resolved and
|
||||
* this display already cached, and it never invents a provider's answer. Its cursor is never
|
||||
* reported anywhere — the moment the server speaks again, the server wins.
|
||||
*/
|
||||
async function runOffline(): Promise<void> {
|
||||
if (stopped || online || !stage || !snapshot) {
|
||||
return;
|
||||
}
|
||||
offlineActive = true;
|
||||
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
|
||||
// way its operator configured rather than the way this file happened to default.
|
||||
stage.setCrossfade(snapshot.crossfadeMs);
|
||||
const atMs = serverNowFrom(snapshot, now());
|
||||
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
||||
const resolution = evaluation.resolution;
|
||||
|
||||
if (resolution.kind === "empty") {
|
||||
// The cached timeline genuinely has nothing eligible. Black is correct, and only when nothing
|
||||
// good is already up.
|
||||
if (stage.currentKey() === undefined) {
|
||||
stage.setSlate(true);
|
||||
}
|
||||
} else if (resolution.kind === "static" || resolution.kind === "selection") {
|
||||
await renderOfflineSelection(resolution.selection, atMs);
|
||||
} else if (resolution.kind === "paused" && resolution.selection) {
|
||||
await renderOfflineSelection(resolution.selection, atMs);
|
||||
}
|
||||
// `hold` is deliberate: keep the last good frame and say nothing.
|
||||
|
||||
report();
|
||||
scheduleOffline(evaluation.nextBoundaryMs, atMs);
|
||||
}
|
||||
|
||||
async function renderOfflineSelection(selection: SceneSelection, atMs: number): Promise<void> {
|
||||
if (!stage || !snapshot) {
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
audienceKey: snapshot.channel.key,
|
||||
localNowMs: now(),
|
||||
paused: snapshot.playback.transport === "paused",
|
||||
remainingMs: snapshot.playback.remainingMs,
|
||||
serverNowMs: atMs,
|
||||
};
|
||||
|
||||
let request: ReturnType<typeof sceneRequestFor>;
|
||||
if (selection.renderer.kind === "native-image") {
|
||||
// Offline, only what is genuinely cached can be painted: a persisted selection carries no
|
||||
// delivery URL, deliberately, so the local copy is the only source there is.
|
||||
const cached = await media.find(
|
||||
signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
}),
|
||||
);
|
||||
request =
|
||||
cached === undefined ? undefined : nativeSceneRequestFor(selection, context, cached);
|
||||
} else {
|
||||
request = sceneRequestFor(selection, context, {
|
||||
canMountContribution: (contributionId) =>
|
||||
options.context.contributions
|
||||
.list("signage.slide-renderer")
|
||||
.some((candidate) => candidate.id === contributionId),
|
||||
});
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
// Nothing local can draw this. The slot is skipped and the last good frame stays.
|
||||
unusable.add(selection.id);
|
||||
return;
|
||||
}
|
||||
const outcome = await stage.show(request);
|
||||
if (outcome.kind === "failed") {
|
||||
unusable.add(selection.id);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind !== "superseded") {
|
||||
renderedSelectionId = selection.id;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleOffline(boundaryMs: number | null, atMs: number): void {
|
||||
if (offlineHandle !== undefined) {
|
||||
clearTimer(offlineHandle);
|
||||
offlineHandle = undefined;
|
||||
}
|
||||
if (stopped || online || boundaryMs === null) {
|
||||
return;
|
||||
}
|
||||
offlineHandle = setTimer(
|
||||
() => {
|
||||
offlineHandle = undefined;
|
||||
void runOffline();
|
||||
},
|
||||
Math.max(250, boundaryMs - atMs),
|
||||
);
|
||||
}
|
||||
|
||||
function exitOffline(): void {
|
||||
offlineActive = false;
|
||||
if (offlineHandle !== undefined) {
|
||||
clearTimer(offlineHandle);
|
||||
offlineHandle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function prefetch(state: DisplayChannelStateView): Promise<void> {
|
||||
for (const selection of state.selections) {
|
||||
if (selection.renderer.kind !== "native-image") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await localUrlFor(selection);
|
||||
} catch {
|
||||
// A slide that cannot be pre-cached is simply not pre-cached. It will be tried again when it
|
||||
// is actually needed, and the frame on screen is unaffected.
|
||||
}
|
||||
}
|
||||
// Superseded bytes are removed **only now**, after everything this state needs is present: a
|
||||
// prune that ran first could leave a screen with less than it had.
|
||||
await media.prune(
|
||||
state.selections
|
||||
.filter((selection) => selection.renderer.kind === "native-image")
|
||||
.map((selection) =>
|
||||
signageMediaKey({
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendition: "display",
|
||||
}),
|
||||
),
|
||||
);
|
||||
cachedAssetCount = await media.count();
|
||||
report();
|
||||
}
|
||||
|
||||
function scheduleRefresh(state: DisplayChannelStateView): void {
|
||||
if (refreshHandle !== undefined) {
|
||||
clearTimer(refreshHandle);
|
||||
refreshHandle = undefined;
|
||||
}
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
// Just after the current slot's deadline, so a missed realtime message costs one late
|
||||
// transition rather than a stuck screen; and never longer than the safety-net interval.
|
||||
const untilDeadline =
|
||||
state.playback.deadlineAtMs === null
|
||||
? REFRESH_INTERVAL_MS
|
||||
: Math.max(250, state.playback.deadlineAtMs - state.serverNowMs + 250);
|
||||
refreshHandle = setTimer(
|
||||
() => {
|
||||
refreshHandle = undefined;
|
||||
void refresh();
|
||||
},
|
||||
Math.min(REFRESH_INTERVAL_MS, untilDeadline),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (stopped || !session) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await options.context.api.request(
|
||||
`channels/${session.channelId}/display-state`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
online = false;
|
||||
await runOffline();
|
||||
return;
|
||||
}
|
||||
await apply((await response.json()) as DisplayChannelStateView);
|
||||
} catch {
|
||||
// The server is unreachable. The frame stays and local continuation takes over; nothing about
|
||||
// a network failure belongs on a lobby wall.
|
||||
online = false;
|
||||
await runOffline();
|
||||
}
|
||||
}
|
||||
|
||||
async function heartbeat(): Promise<void> {
|
||||
if (stopped || !session || !telemetry) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
||||
body: JSON.stringify({
|
||||
cachedAssetCount,
|
||||
error:
|
||||
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
|
||||
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
|
||||
reportedContributionId:
|
||||
lastState?.current?.renderer.kind === "contribution"
|
||||
? lastState.current.renderer.contributionId
|
||||
: null,
|
||||
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
|
||||
reportedProviderId: lastState?.current?.providerId ?? null,
|
||||
reportedSelectionId: renderedSelectionId ?? null,
|
||||
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Telemetry is best effort and never affects what is on screen.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apply,
|
||||
diagnostics,
|
||||
|
||||
async start(): Promise<void> {
|
||||
stopped = false;
|
||||
stage = createSceneStage({
|
||||
crossfadeMs: 1000,
|
||||
...(options.decodeImage ? { decodeImage: options.decodeImage } : {}),
|
||||
mountContribution: async ({ container, contributionId, input }) => {
|
||||
// The platform loads, verifies, and mounts the renderer. Signage hands it identity and its
|
||||
// own opaque input, and receives a handle — never a URL, and never the module's code.
|
||||
const mounted = await options.context.contributions.mount({
|
||||
container,
|
||||
contributionId,
|
||||
input: input as never,
|
||||
});
|
||||
return {
|
||||
unmount: () => mounted.unmount(),
|
||||
update: (next: unknown) => mounted.update(next as never),
|
||||
};
|
||||
},
|
||||
root: options.root,
|
||||
setTimer,
|
||||
});
|
||||
|
||||
// A frame from the last known good state goes up before the first request returns, so a
|
||||
// reload during a service does not stare at black while a fetch completes.
|
||||
try {
|
||||
const restored = await store.read<OfflineSnapshot>(SNAPSHOT_STORAGE);
|
||||
if (restored?.version === 1) {
|
||||
snapshot = restored;
|
||||
await runOffline();
|
||||
}
|
||||
} catch {
|
||||
// Nothing persisted, or nothing readable. The display simply starts from the server.
|
||||
}
|
||||
|
||||
if (telemetry) {
|
||||
try {
|
||||
const response = await options.context.api.request("viewers/session", {
|
||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
session = (await response.json()) as DisplaySessionView;
|
||||
online = true;
|
||||
}
|
||||
} catch {
|
||||
online = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
subscription = options.context.realtime.subscribe("display-state", {
|
||||
onSnapshot: (state) => onRealtime(state),
|
||||
onStatus: (status) => {
|
||||
online = status === "connected";
|
||||
report();
|
||||
},
|
||||
onUpdate: (state) => onRealtime(state),
|
||||
});
|
||||
heartbeatHandle = setInterval(
|
||||
() => void heartbeat(),
|
||||
session.heartbeatIntervalMs,
|
||||
) as unknown;
|
||||
await refresh();
|
||||
void heartbeat();
|
||||
}
|
||||
report();
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
stopped = true;
|
||||
subscription?.close();
|
||||
subscription = undefined;
|
||||
if (heartbeatHandle !== undefined) {
|
||||
clearInterval(heartbeatHandle as never);
|
||||
heartbeatHandle = undefined;
|
||||
}
|
||||
if (refreshHandle !== undefined) {
|
||||
clearTimer(refreshHandle);
|
||||
refreshHandle = undefined;
|
||||
}
|
||||
exitOffline();
|
||||
await stage?.destroy();
|
||||
stage = undefined;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A realtime frame carries compact authoritative state and revisions, never content. A revision
|
||||
* this display has not applied means "fetch the state you are authorized for", which is what
|
||||
* closes the GET-then-subscribe race without a second transport (ADR 0005).
|
||||
*/
|
||||
function onRealtime(payload: unknown): void {
|
||||
if (stopped || !session) {
|
||||
return;
|
||||
}
|
||||
const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
|
||||
if (!Array.isArray(groups)) {
|
||||
return;
|
||||
}
|
||||
const mine = groups.find((group) => group.groupId === session?.groupId);
|
||||
if (!mine) {
|
||||
return;
|
||||
}
|
||||
online = true;
|
||||
const revision = typeof mine.revision === "number" ? mine.revision : -1;
|
||||
const selectionRevision =
|
||||
typeof mine.selectionRevision === "number" ? mine.selectionRevision : null;
|
||||
if (revision === appliedRevision && selectionRevision === appliedSelectionRevision) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SignageSlate } from "../browser/slate.js";
|
||||
import { createDisplayController } from "./controller.js";
|
||||
import displayStyles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Signage's full-viewport display.
|
||||
*
|
||||
* React renders the fixed furniture — the stage's root, the blackout cover, and the slate — and the
|
||||
* controller owns everything that moves. That split is deliberate: the two scene layers are created
|
||||
* once and never rebuilt, and a React tree that re-created them on every state change would be the
|
||||
* `v1` renderer again.
|
||||
*
|
||||
* The artifact performs no permission check of its own. The host resolved an authorization-bearing
|
||||
* descriptor before these bytes were ever fetched, and a television has nobody standing at it to act
|
||||
* on a denial — telling a lobby wall "you may not see this" would only report something about the
|
||||
* installation's authorization to whoever walks past.
|
||||
*/
|
||||
function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = stageRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const controller = createDisplayController({ context, root });
|
||||
void controller.start();
|
||||
return () => {
|
||||
void controller.stop();
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
return (
|
||||
<main className="signage-display" data-testid="signage-display">
|
||||
<div className="signage-stage" data-testid="signage-stage" ref={stageRef}>
|
||||
{/* Shown only when the stage says there is nothing renderable *and* no good frame. */}
|
||||
<div className="signage-slate-host">
|
||||
<SignageSlate />
|
||||
</div>
|
||||
{/* A deliberate blackout: pure black over everything, while the hidden timeline runs on. */}
|
||||
<div aria-hidden="true" className="signage-blackout" data-testid="signage-blackout" />
|
||||
{/* Continuing from this screen's own last authoritative state. Small and in a corner: a
|
||||
congregation is looking at this wall, and a network is not their problem. */}
|
||||
<p className="signage-offline-note" data-testid="signage-offline-note">
|
||||
Offline — continuing from the last update
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiled full-viewport display artifact. It uses the same role-neutral browser ABI as the
|
||||
* management UI; its separate manifest entrypoint — not a mode flag — is what tells a host which
|
||||
* artifact it is mounting.
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.0",
|
||||
styles: displayStyles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<SignageDisplay context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* The Signage display artifact's own stylesheet, isolated from the management UI's exactly as it is
|
||||
* from the Dashboard's: two artifacts of one module share no styles unless they share a source file
|
||||
* (ADR 0040).
|
||||
*
|
||||
* `:host { display: block }` overrides the host's transparent default deliberately — a full-viewport
|
||||
* display needs a real box, and the host installs its base rules before this stylesheet precisely so
|
||||
* an artifact can override them by ordinary cascade order.
|
||||
*
|
||||
* Almost every value here is a literal rather than a token, and that is the one deliberate exception
|
||||
* in this module. A television is not a themed surface: its ground is black because anything else
|
||||
* would show as a frame around a church's own artwork, and the slate must stay unmistakable whatever
|
||||
* a deployment's theme becomes. The Dashboard's theme never recolours a provider's output or an
|
||||
* uploaded image.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The box model for the tree Signage owns. An isolated root inherits nothing from the page around
|
||||
* it, so a full-viewport layout that assumes `border-box` has to say so.
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
--signage-slate-bar-one: #6f6f6f;
|
||||
--signage-slate-bar-two: #6f6f2f;
|
||||
--signage-slate-bar-three: #2f6f6f;
|
||||
--signage-slate-bar-four: #2f6f2f;
|
||||
--signage-slate-bar-five: #6f2f6f;
|
||||
--signage-slate-bar-six: #6f2f2f;
|
||||
}
|
||||
|
||||
.signage-display {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--ekklesia-typography-family-sans);
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-stage {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
min-height: 100vh;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* The two permanent scene layers.
|
||||
*
|
||||
* Created once by the stage and never rebuilt. Only their contents, stacking, and opacity change —
|
||||
* and an *outgoing* layer is never faded, which is what keeps the black root from showing through
|
||||
* the middle of a transition.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-layer {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transition-duration: 0ms;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: linear;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-layer__background {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-layer__background-image {
|
||||
display: none;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
transform: scale(1.08);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-layer__darken {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-layer__foreground {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-layer__renderer {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Blackout and the slate, both states of the stage root.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-blackout {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 200ms linear;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
[data-blackout="true"] > .signage-blackout {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-slate-host {
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
[data-slate="true"] > .signage-slate-host {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-slate {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-slate__bars {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.signage-slate__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signage-slate__bar--one {
|
||||
background: var(--signage-slate-bar-one);
|
||||
}
|
||||
|
||||
.signage-slate__bar--two {
|
||||
background: var(--signage-slate-bar-two);
|
||||
}
|
||||
|
||||
.signage-slate__bar--three {
|
||||
background: var(--signage-slate-bar-three);
|
||||
}
|
||||
|
||||
.signage-slate__bar--four {
|
||||
background: var(--signage-slate-bar-four);
|
||||
}
|
||||
|
||||
.signage-slate__bar--five {
|
||||
background: var(--signage-slate-bar-five);
|
||||
}
|
||||
|
||||
.signage-slate__bar--six {
|
||||
background: var(--signage-slate-bar-six);
|
||||
}
|
||||
|
||||
.signage-slate__message {
|
||||
background: #000000;
|
||||
flex: 0 0 auto;
|
||||
font-size: 4vh;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
padding: 3vh 0;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* The offline indicator.
|
||||
*
|
||||
* Deliberately small, deliberately in a corner, and deliberately not an error. A screen continuing
|
||||
* from its own last authoritative state is working; a full-page failure message on a lobby wall
|
||||
* would be the actual failure.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-offline-note {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 0.6vh;
|
||||
bottom: 2vh;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 1.6vh;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
padding: 0.8vh 1.4vh;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 2vh;
|
||||
transition: opacity 400ms linear;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
[data-offline="true"] > .signage-offline-note {
|
||||
opacity: 1;
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled
|
||||
* CSS **text**, as a string (ADR 0040).
|
||||
*
|
||||
* ```ts
|
||||
* import styles from "./styles.css";
|
||||
* export default defineModuleUi({ id, version, styles, mount });
|
||||
* ```
|
||||
*
|
||||
* There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact,
|
||||
* and the host installs it into the artifact's isolated root at mount. Copy this file into your own
|
||||
* module and include it in your UI TypeScript project.
|
||||
*/
|
||||
declare module "*.css" {
|
||||
const stylesheet: string;
|
||||
export default stylesheet;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||
import {
|
||||
channelGroups,
|
||||
channels,
|
||||
imageAssets,
|
||||
imageRenditions,
|
||||
playbackStates,
|
||||
providerSources,
|
||||
selections,
|
||||
selectionVariants,
|
||||
timelineSlots,
|
||||
timelines,
|
||||
viewers,
|
||||
} from "./tables.js";
|
||||
|
||||
export {
|
||||
channelGroups,
|
||||
channels,
|
||||
imageAssets,
|
||||
imageRenditions,
|
||||
playbackStates,
|
||||
providerSources,
|
||||
selections,
|
||||
selectionVariants,
|
||||
timelineSlots,
|
||||
timelines,
|
||||
viewers,
|
||||
} from "./tables.js";
|
||||
|
||||
/**
|
||||
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
|
||||
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
|
||||
* connects to nothing and reads nothing.
|
||||
*/
|
||||
export default defineModuleSchema({
|
||||
moduleId: "signage",
|
||||
tables: {
|
||||
channelGroups,
|
||||
channels,
|
||||
imageAssets,
|
||||
imageRenditions,
|
||||
playbackStates,
|
||||
providerSources,
|
||||
selections,
|
||||
selectionVariants,
|
||||
timelines,
|
||||
timelineSlots,
|
||||
viewers,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,485 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTableCreator,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Signage's own PostgreSQL tables.
|
||||
*
|
||||
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file
|
||||
* without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the
|
||||
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core
|
||||
* validates it independently of this helper.
|
||||
*
|
||||
* Two rules hold everywhere below, and both are architecture rather than style:
|
||||
*
|
||||
* - **No foreign key leaves this module.** A provider's id, a source id, an item id, and a Core
|
||||
* device id are all stored as opaque text. Signage cannot join to another module's data and must
|
||||
* never be able to (ADR 0007 of the platform, ADR 0001 here).
|
||||
* - **No signed delivery URL is ever stored.** Objects are addressed by their logical key, and a
|
||||
* read URL is requested fresh when something is about to be delivered — a stored URL is a
|
||||
* credential with an expiry date in it.
|
||||
*/
|
||||
const moduleTable = pgTableCreator((localName) => `signage_${localName}`);
|
||||
|
||||
/** Timestamps are absolute instants. The installation's IANA zone is an operator setting used to
|
||||
* write and read them for a person, never to interpret one at run time. */
|
||||
const instant = (name: string) => timestamp(name, { mode: "date", withTimezone: true });
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Native images.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One native image, identified by the SHA-256 of the bytes an operator uploaded (ADR 0007).
|
||||
*
|
||||
* `content_sha256` is unique, so uploading the same file twice returns the same asset instead of
|
||||
* storing a second copy, and re-running a failed ingest is idempotent.
|
||||
*/
|
||||
export const imageAssets = moduleTable(
|
||||
"image_assets",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
contentSha256: text("content_sha256").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
originalFilename: text("original_filename").notNull(),
|
||||
/** The archived original, byte for byte. Signage is not entitled to rewrite a church's file. */
|
||||
originalObjectKey: text("original_object_key").notNull(),
|
||||
originalContentType: text("original_content_type").notNull(),
|
||||
originalByteSize: bigint("original_byte_size", { mode: "number" }).notNull(),
|
||||
/** How the original should appear once its own orientation is applied. */
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
/** What is actually encoded in the file, kept for diagnostics without implying the archived
|
||||
* bytes were rewritten. */
|
||||
encodedWidth: integer("encoded_width").notNull(),
|
||||
encodedHeight: integer("encoded_height").notNull(),
|
||||
orientation: integer("orientation"),
|
||||
hasAlpha: boolean("has_alpha").notNull().default(false),
|
||||
/** `pending` while ingest is in flight, `ready` once every rendition is committed, `failed` when
|
||||
* ingest gave up. Only `ready` is ever selectable by playback. */
|
||||
status: text("status").notNull().default("pending"),
|
||||
/** A stable sanitized code, never a caught exception, a path, or a provider message. */
|
||||
failureCode: text("failure_code"),
|
||||
/** Null means permanent. Local age never invents an expiration. */
|
||||
expiresAt: instant("expires_at"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_image_assets_content_sha256_key").on(table.contentSha256),
|
||||
index("signage_image_assets_status_idx").on(table.status),
|
||||
check(
|
||||
"signage_image_assets_status_check",
|
||||
sql`${table.status} in ('pending', 'ready', 'failed')`,
|
||||
),
|
||||
check(
|
||||
"signage_image_assets_dimensions_check",
|
||||
sql`${table.width} > 0 and ${table.height} > 0 and ${table.encodedWidth} > 0 and ${table.encodedHeight} > 0`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A generated rendition of one asset. Immutable: different bytes are a different asset, so a
|
||||
* rendition is never rewritten in place.
|
||||
*/
|
||||
export const imageRenditions = moduleTable(
|
||||
"image_renditions",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
assetId: uuid("asset_id")
|
||||
.notNull()
|
||||
.references(() => imageAssets.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
contentSha256: text("content_sha256").notNull(),
|
||||
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_image_renditions_asset_kind_key").on(table.assetId, table.kind),
|
||||
check("signage_image_renditions_kind_check", sql`${table.kind} in ('display', 'thumbnail')`),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Timelines, sources, and slots.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** A saved, ordered, repeating pattern. It holds no cursor and no clock: running one is what a
|
||||
* channel group does, and the same timeline may run on two groups without interfering (ADR 0003). */
|
||||
export const timelines = moduleTable(
|
||||
"timelines",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
/** Timeline-level eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||
eligibleFrom: instant("eligible_from"),
|
||||
eligibleUntil: instant("eligible_until"),
|
||||
/** Increments only on a semantic change, so a client can compare and refetch. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("signage_timelines_name_idx").on(table.name),
|
||||
check(
|
||||
"signage_timelines_eligibility_check",
|
||||
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One **selection scope**: a provider's source as used by a timeline (ADR 0003).
|
||||
*
|
||||
* Every slot referencing this row passes the same `selection_scope` to `resolveNext`, so five
|
||||
* Announcement slots walk one rotation rather than five. Two rows for the same provider source are
|
||||
* two deliberately independent rotations.
|
||||
*
|
||||
* `provider_id` and `source_id` are opaque text. There is no foreign key here and there never can be:
|
||||
* the provider is another module.
|
||||
*/
|
||||
export const providerSources = moduleTable(
|
||||
"provider_sources",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
timelineId: uuid("timeline_id")
|
||||
.notNull()
|
||||
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
/** What an operator sees. Captured from provider discovery when the source was added, so the
|
||||
* editor still reads sensibly when that provider is temporarily not running. */
|
||||
label: text("label").notNull(),
|
||||
/** Stable, opaque, and deliberately not derived from the timeline or slot id — either would
|
||||
* silently reset a provider's rotation when an operator renamed or reordered something. */
|
||||
selectionScope: text("selection_scope").notNull(),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_provider_sources_selection_scope_key").on(table.selectionScope),
|
||||
index("signage_provider_sources_timeline_idx").on(table.timelineId),
|
||||
index("signage_provider_sources_provider_idx").on(table.providerId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One position in a timeline's pattern.
|
||||
*
|
||||
* `position` is unique per timeline and kept contiguous from zero inside the transaction that
|
||||
* changes it — a gap would make "the next slot" ambiguous exactly when playback needs it least. The
|
||||
* slot's `id` is stable across every reorder, which is what lets history and a display's cursor
|
||||
* survive an operator dragging a row.
|
||||
*/
|
||||
export const timelineSlots = moduleTable(
|
||||
"timeline_slots",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
timelineId: uuid("timeline_id")
|
||||
.notNull()
|
||||
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull(),
|
||||
/** `provider-source` asks for the next item; `pinned-item` shows exactly one. A native image is
|
||||
* a pinned item of Signage's own built-in image provider (ADR 0001). */
|
||||
kind: text("kind").notNull(),
|
||||
sourceId: uuid("source_id").references(() => providerSources.id, { onDelete: "cascade" }),
|
||||
/** Set for a pinned item only. Opaque: another module's identity for its own content. */
|
||||
providerId: text("provider_id"),
|
||||
itemId: text("item_id"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
durationOverrideMs: integer("duration_override_ms"),
|
||||
/** Per-slot eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||
eligibleFrom: instant("eligible_from"),
|
||||
eligibleUntil: instant("eligible_until"),
|
||||
/** Native-image presentation overrides. Null means "take the installation default". These apply
|
||||
* to native images only; an external provider owns its rendering completely. */
|
||||
backgroundMode: text("background_mode"),
|
||||
backgroundColor: text("background_color"),
|
||||
backgroundDarkened: boolean("background_darkened"),
|
||||
blurPixels: integer("blur_pixels"),
|
||||
foregroundShadow: boolean("foreground_shadow"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_timeline_slots_position_key").on(table.timelineId, table.position),
|
||||
index("signage_timeline_slots_source_idx").on(table.sourceId),
|
||||
check("signage_timeline_slots_position_check", sql`${table.position} >= 0`),
|
||||
check(
|
||||
"signage_timeline_slots_kind_check",
|
||||
sql`${table.kind} in ('provider-source', 'pinned-item')`,
|
||||
),
|
||||
// Exactly one of the two shapes is populated, so a slot can never be both or neither.
|
||||
check(
|
||||
"signage_timeline_slots_reference_check",
|
||||
sql`(${table.kind} = 'provider-source' and ${table.sourceId} is not null and ${table.providerId} is null and ${table.itemId} is null)
|
||||
or (${table.kind} = 'pinned-item' and ${table.sourceId} is null and ${table.providerId} is not null and ${table.itemId} is not null)`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_duration_check",
|
||||
sql`${table.durationOverrideMs} is null or ${table.durationOverrideMs} > 0`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_eligibility_check",
|
||||
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||
),
|
||||
check(
|
||||
"signage_timeline_slots_background_check",
|
||||
sql`${table.backgroundMode} is null or ${table.backgroundMode} in ('blurred', 'solid', 'black')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Channel groups, channels, and authoritative playback.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** One synchronized group: one authoritative timeline clock, one active timeline, one cursor
|
||||
* (ADR 0004). A genuinely independent clock is another group. */
|
||||
export const channelGroups = moduleTable(
|
||||
"channel_groups",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
/** Increments when the group's channel membership changes, so a client knows to refetch. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index("signage_channel_groups_name_idx").on(table.name)],
|
||||
);
|
||||
|
||||
/** An audience within a group. Channels differ in exactly one way: which variant of a resolved
|
||||
* selection they render. Exactly one channel per group is the default. */
|
||||
export const channels = moduleTable(
|
||||
"channels",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
/** The audience key handed to a provider. `default` is the base channel's. */
|
||||
key: text("key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_channels_group_key_key").on(table.groupId, table.key),
|
||||
// Exactly one default per group, enforced by the database rather than by convention.
|
||||
uniqueIndex("signage_channels_group_default_key")
|
||||
.on(table.groupId)
|
||||
.where(sql`${table.isDefault}`),
|
||||
check("signage_channels_key_check", sql`${table.key} ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'`),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per channel group: the authoritative clock (ADR 0005).
|
||||
*
|
||||
* `slot_started_at` and `slot_deadline_at` are **absolute instants**, never a remaining count. That
|
||||
* is what lets two viewers who connected minutes apart agree, and what lets a restart resume the
|
||||
* item it was already showing instead of choosing a new one.
|
||||
*/
|
||||
export const playbackStates = moduleTable(
|
||||
"playback_states",
|
||||
{
|
||||
groupId: uuid("group_id")
|
||||
.primaryKey()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
activeTimelineId: uuid("active_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** Queued activation, applied at the next safe slot boundary. */
|
||||
pendingTimelineId: uuid("pending_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pendingRequestedAt: instant("pending_requested_at"),
|
||||
/** Which resolved selection is on screen. Nulled rather than cascaded when history is pruned. */
|
||||
currentSelectionId: uuid("current_selection_id"),
|
||||
/** Where in the pattern the cursor is. Kept beside the selection so an empty or skipped slot
|
||||
* still advances deterministically. */
|
||||
currentSlotId: uuid("current_slot_id"),
|
||||
slotStartedAt: instant("slot_started_at"),
|
||||
slotDeadlineAt: instant("slot_deadline_at"),
|
||||
/** Preserved across pause and static hold, so releasing either continues the same slide. */
|
||||
pausedRemainingMs: integer("paused_remaining_ms"),
|
||||
paused: boolean("paused").notNull().default(false),
|
||||
/** Covers the output with black while the hidden timeline keeps running. */
|
||||
blackout: boolean("blackout").notNull().default(false),
|
||||
/** A native image held on screen while the normal cursor and remainder stay preserved. */
|
||||
staticAssetId: uuid("static_asset_id").references(() => imageAssets.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** An activation an operator asked for in advance. One per group: "activate this timeline at
|
||||
* this instant" is a single intention, and a queue of them would be a scheduling product
|
||||
* Signage does not have. */
|
||||
scheduledTimelineId: uuid("scheduled_timeline_id").references(() => timelines.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
scheduledActivationAt: instant("scheduled_activation_at"),
|
||||
/** Monotonic. Increments only on a semantic change; an exact no-op commits nothing. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
/** The next sequence number a resolved selection for this group will take. */
|
||||
nextSequence: bigint("next_sequence", { mode: "number" }).notNull().default(1),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"signage_playback_states_remaining_check",
|
||||
sql`${table.pausedRemainingMs} is null or ${table.pausedRemainingMs} >= 0`,
|
||||
),
|
||||
check(
|
||||
"signage_playback_states_window_check",
|
||||
sql`${table.slotStartedAt} is null or ${table.slotDeadlineAt} is null or ${table.slotStartedAt} <= ${table.slotDeadlineAt}`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Resolved selections and bounded history.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One logical selection, resolved once for the whole channel group (ADR 0004).
|
||||
*
|
||||
* Persisting this is what makes two viewers that joined at different times render the same slot,
|
||||
* what lets `previous` walk actual playback rather than the slot order, what makes restart recovery
|
||||
* show the same item, and what gives offline continuation something real to continue from.
|
||||
*
|
||||
* `slot_id` is nulled rather than cascaded when a slot is deleted: history is a record of what
|
||||
* happened, and an operator editing a timeline must not erase it.
|
||||
*/
|
||||
export const selections = moduleTable(
|
||||
"selections",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||
/** Monotonic per group. The ordering `previous` walks. */
|
||||
sequence: bigint("sequence", { mode: "number" }).notNull(),
|
||||
timelineId: uuid("timeline_id"),
|
||||
slotId: uuid("slot_id").references(() => timelineSlots.id, { onDelete: "set null" }),
|
||||
/** The selection scope this item came from, copied at resolution time. Kept even after the
|
||||
* source row is gone, so history stays readable and a provider's rotation can be re-seeded from
|
||||
* what actually played rather than from the beginning. Null for a pinned item. */
|
||||
selectionScope: text("selection_scope"),
|
||||
/** Opaque provider identity, and the provider's own identity for its content. */
|
||||
providerId: text("provider_id").notNull(),
|
||||
itemId: text("item_id").notNull(),
|
||||
/** Changes whenever the payload changes and the item stays the same thing. */
|
||||
itemRevision: text("item_revision").notNull(),
|
||||
/** Which renderer draws this. Signage never imports it; the display asks the host to mount it. */
|
||||
rendererContributionId: text("renderer_contribution_id"),
|
||||
rendererContributionVersion: text("renderer_contribution_version"),
|
||||
/** Opaque presentation values the provider itself supplied to its own renderer. */
|
||||
presentation: jsonb("presentation"),
|
||||
durationMs: integer("duration_ms").notNull(),
|
||||
resolvedAt: instant("resolved_at").notNull().defaultNow(),
|
||||
startedAt: instant("started_at"),
|
||||
deadlineAt: instant("deadline_at"),
|
||||
/** When this selection stops being valid, as supplied by the provider. */
|
||||
availableUntil: instant("available_until"),
|
||||
/** The provider said this item is explicitly gone. Removed at the next safe boundary. */
|
||||
withdrawn: boolean("withdrawn").notNull().default(false),
|
||||
/** A provider call failed temporarily and this is the last known good answer. */
|
||||
stale: boolean("stale").notNull().default(false),
|
||||
/** Increments when a refresh changes payloads without changing which item this is — which is
|
||||
* what updates a mounted renderer in place without restarting its slot. */
|
||||
revision: integer("revision").notNull().default(1),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_selections_group_sequence_key").on(table.groupId, table.sequence),
|
||||
index("signage_selections_group_resolved_idx").on(table.groupId, table.resolvedAt),
|
||||
index("signage_selections_provider_item_idx").on(table.providerId, table.itemId),
|
||||
check("signage_selections_duration_check", sql`${table.durationMs} > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/** One audience's payload for one selection. The default variant is always present; an
|
||||
* audience-specific one exists only when the provider offered it. */
|
||||
export const selectionVariants = moduleTable(
|
||||
"selection_variants",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
selectionId: uuid("selection_id")
|
||||
.notNull()
|
||||
.references(() => selections.id, { onDelete: "cascade" }),
|
||||
/** The audience key this payload is for. The default variant uses the literal `default` key. */
|
||||
audienceKey: text("audience_key").notNull(),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
/** Opaque JSON. Signage validates that it *is* finite JSON and never what it means. */
|
||||
payload: jsonb("payload").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_selection_variants_selection_audience_key").on(
|
||||
table.selectionId,
|
||||
table.audienceKey,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Viewers.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One display Signage has seen.
|
||||
*
|
||||
* `viewer_key` is the persistent identity a display keeps in its own IndexedDB, so a reconnect is
|
||||
* recognized as the same screen rather than a new one. `device_id` is the **safe** Core device
|
||||
* identifier a paired display's principal carries — an opaque correlation value. Signage never sees,
|
||||
* stores, or handles a device credential, and pairing remains entirely Core's.
|
||||
*/
|
||||
export const viewers = moduleTable(
|
||||
"viewers",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
viewerKey: text("viewer_key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
deviceId: text("device_id"),
|
||||
channelId: uuid("channel_id").references(() => channels.id, { onDelete: "set null" }),
|
||||
lastSeenAt: instant("last_seen_at"),
|
||||
/** What the viewer last reported it was actually showing. Telemetry: a display never mutates
|
||||
* authoritative playback, and a local cursor is never accepted as authority. */
|
||||
reportedTimelineId: uuid("reported_timeline_id"),
|
||||
reportedSelectionId: uuid("reported_selection_id"),
|
||||
reportedProviderId: text("reported_provider_id"),
|
||||
reportedContributionId: text("reported_contribution_id"),
|
||||
reportedPlaybackRevision: integer("reported_playback_revision"),
|
||||
/** Whether the viewer says it has enough cached to keep rendering through an outage. */
|
||||
offlineReady: boolean("offline_ready").notNull().default(false),
|
||||
cachedAssetCount: integer("cached_asset_count").notNull().default(0),
|
||||
/** A stable sanitized code and a bounded operator-facing detail. Never an exception, a URL, a
|
||||
* payload, or a stack. */
|
||||
lastErrorCode: text("last_error_code"),
|
||||
lastErrorDetail: text("last_error_detail"),
|
||||
lastErrorAt: instant("last_error_at"),
|
||||
createdAt: instant("created_at").notNull().defaultNow(),
|
||||
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("signage_viewers_viewer_key_key").on(table.viewerKey),
|
||||
index("signage_viewers_channel_idx").on(table.channelId),
|
||||
index("signage_viewers_last_seen_idx").on(table.lastSeenAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { ModuleStorage } from "@ekklesia/module-sdk";
|
||||
import {
|
||||
resolveNativeScene,
|
||||
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||
SIGNAGE_NATIVE_RENDERER,
|
||||
type SignageNativePresentation,
|
||||
} from "../../shared/native-images.js";
|
||||
import type { SignageSettings } from "../../shared/settings.js";
|
||||
import { createImageDeliveryUrls } from "../images/service.js";
|
||||
import type { PlaybackSnapshot } from "../playback/service.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import type {
|
||||
SignageStore,
|
||||
StoredChannel,
|
||||
StoredSelection,
|
||||
StoredSlot,
|
||||
StoredTimelineDetail,
|
||||
} from "../store/types.js";
|
||||
|
||||
/**
|
||||
* Everything one channel's display needs to render, and to keep rendering when its network drops.
|
||||
*
|
||||
* Three groups of things travel together here, and each earns its place:
|
||||
*
|
||||
* - **What is on screen now**, resolved for this channel's audience, with fresh delivery URLs for a
|
||||
* native image. This is the frame.
|
||||
* - **The timeline's shape** — slot identities, eligibility windows, and estimated durations. This
|
||||
* is what lets a disconnected display advance its own cursor with the same arithmetic the server
|
||||
* reconciles with, rather than guessing (ADR 0006).
|
||||
* - **Recent resolved selections**, again for this audience. This is what a disconnected display
|
||||
* *renders* when its cursor lands on a slot it has already seen. It never resolves new provider
|
||||
* content offline, because inventing a provider's answer is worse than keeping the last frame.
|
||||
*
|
||||
* A delivery URL is minted here and never stored: it is a bearer capability with an expiry in it,
|
||||
* and a display refreshes state long before one lapses.
|
||||
*/
|
||||
|
||||
export interface DisplayMedia {
|
||||
readonly url: string;
|
||||
readonly expiresAtMs: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface DisplaySelection {
|
||||
readonly id: string;
|
||||
readonly revision: number;
|
||||
readonly slotId: string | null;
|
||||
readonly providerId: string;
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
readonly durationMs: number;
|
||||
readonly availableUntilMs: number | null;
|
||||
readonly withdrawn: boolean;
|
||||
readonly stale: boolean;
|
||||
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
|
||||
* platform's contribution ABI by id. A display never receives a URL for either. */
|
||||
readonly renderer:
|
||||
| { readonly kind: "native-image" }
|
||||
| {
|
||||
readonly kind: "contribution";
|
||||
readonly contributionId: string;
|
||||
readonly contributionVersion: string;
|
||||
}
|
||||
| { readonly kind: "unavailable" };
|
||||
/** The provider's own payload for this channel's audience, or its default. */
|
||||
readonly payload: unknown;
|
||||
readonly presentation: unknown;
|
||||
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
|
||||
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
|
||||
readonly scene?: ReturnType<typeof resolveNativeScene>;
|
||||
}
|
||||
|
||||
export interface DisplayTimelineSlot {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly enabled: boolean;
|
||||
readonly eligibleFromMs: number | null;
|
||||
readonly eligibleUntilMs: number | null;
|
||||
readonly estimatedDurationMs: number;
|
||||
}
|
||||
|
||||
export interface DisplayTimeline {
|
||||
readonly id: string;
|
||||
readonly revision: number;
|
||||
readonly eligibleFromMs: number | null;
|
||||
readonly eligibleUntilMs: number | null;
|
||||
readonly slots: readonly DisplayTimelineSlot[];
|
||||
}
|
||||
|
||||
export interface DisplayChannelState {
|
||||
readonly serverNowMs: number;
|
||||
readonly channel: {
|
||||
readonly id: string;
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly groupId: string;
|
||||
};
|
||||
readonly crossfadeMs: number;
|
||||
readonly playback: PlaybackSnapshot;
|
||||
readonly timeline: DisplayTimeline | null;
|
||||
readonly current: DisplaySelection | null;
|
||||
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
|
||||
* *over* a preserved cursor rather than replacing it. */
|
||||
readonly staticHold: DisplaySelection | null;
|
||||
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
|
||||
readonly selections: readonly DisplaySelection[];
|
||||
}
|
||||
|
||||
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
|
||||
* enough that the response stays a response. */
|
||||
export const SIGNAGE_DISPLAY_SELECTION_LIMIT = 24;
|
||||
|
||||
function slotPresentationFor(
|
||||
slot: StoredSlot | undefined,
|
||||
settings: SignageSettings,
|
||||
): SignageNativePresentation {
|
||||
const defaults = settings.nativePresentation;
|
||||
return {
|
||||
backgroundColor: slot?.presentation.backgroundColor ?? defaults.backgroundColor,
|
||||
backgroundDarkened: slot?.presentation.backgroundDarkened ?? defaults.backgroundDarkened,
|
||||
backgroundMode: slot?.presentation.backgroundMode ?? defaults.backgroundMode,
|
||||
blurPixels: slot?.presentation.blurPixels ?? defaults.blurPixels,
|
||||
foregroundShadow: slot?.presentation.foregroundShadow ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadFor(selection: StoredSelection, audienceKey: string): unknown {
|
||||
const exact = selection.variants.find((variant) => variant.audienceKey === audienceKey);
|
||||
if (exact) {
|
||||
return exact.payload;
|
||||
}
|
||||
// The fallback *is* the contract: a provider with nothing audience-specific returns one variant,
|
||||
// and every channel renders it (ADR 0002).
|
||||
return selection.variants.find((variant) => variant.isDefault)?.payload ?? null;
|
||||
}
|
||||
|
||||
interface NativePayloadShape {
|
||||
readonly imageId?: unknown;
|
||||
readonly width?: unknown;
|
||||
readonly height?: unknown;
|
||||
readonly renditionKeys?: { readonly display?: unknown; readonly thumbnail?: unknown };
|
||||
}
|
||||
|
||||
export interface BuildDisplayStateInput {
|
||||
readonly store: SignageStore;
|
||||
readonly storage: ModuleStorage;
|
||||
readonly registry: SignageProviderRegistry;
|
||||
readonly settings: SignageSettings;
|
||||
readonly channel: StoredChannel;
|
||||
readonly playback: PlaybackSnapshot;
|
||||
readonly atMs: number;
|
||||
}
|
||||
|
||||
export async function buildDisplayChannelState(
|
||||
input: BuildDisplayStateInput,
|
||||
): Promise<DisplayChannelState> {
|
||||
const group = await input.store.findChannelGroup(input.channel.groupId);
|
||||
const channelName = group?.channels.find((c) => c.id === input.channel.id)?.name ?? "Display";
|
||||
const timelineDetail =
|
||||
input.playback.activeTimelineId === null
|
||||
? undefined
|
||||
: await input.store.findTimeline(input.playback.activeTimelineId);
|
||||
|
||||
const history = await input.store.listSelections(
|
||||
input.channel.groupId,
|
||||
SIGNAGE_DISPLAY_SELECTION_LIMIT,
|
||||
);
|
||||
const rendered = await Promise.all(
|
||||
history.map((selection) =>
|
||||
renderSelection(input, selection, timelineDetail, input.channel.key),
|
||||
),
|
||||
);
|
||||
const current =
|
||||
rendered.find((selection) => selection.id === input.playback.currentSelectionId) ?? null;
|
||||
|
||||
return {
|
||||
channel: {
|
||||
groupId: input.channel.groupId,
|
||||
id: input.channel.id,
|
||||
key: input.channel.key,
|
||||
name: channelName,
|
||||
},
|
||||
crossfadeMs: input.settings.crossfadeMs,
|
||||
current,
|
||||
playback: input.playback,
|
||||
selections: rendered,
|
||||
serverNowMs: input.atMs,
|
||||
staticHold:
|
||||
input.playback.staticAssetId === null
|
||||
? null
|
||||
: await renderStaticHold(input, input.playback.staticAssetId),
|
||||
timeline: timelineDetail ? describeTimeline(input, timelineDetail) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeTimeline(
|
||||
input: BuildDisplayStateInput,
|
||||
detail: StoredTimelineDetail,
|
||||
): DisplayTimeline {
|
||||
return {
|
||||
eligibleFromMs: detail.timeline.eligibleFrom?.getTime() ?? null,
|
||||
eligibleUntilMs: detail.timeline.eligibleUntil?.getTime() ?? null,
|
||||
id: detail.timeline.id,
|
||||
revision: detail.timeline.revision,
|
||||
slots: detail.slots.map((slot) => {
|
||||
const providerId =
|
||||
slot.kind === "pinned-item"
|
||||
? slot.providerId
|
||||
: (detail.sources.find((source) => source.id === slot.sourceId)?.providerId ?? null);
|
||||
return {
|
||||
eligibleFromMs: slot.eligibleFrom?.getTime() ?? null,
|
||||
eligibleUntilMs: slot.eligibleUntil?.getTime() ?? null,
|
||||
enabled: slot.enabled,
|
||||
// The same estimate the server uses to skip whole passes, so a disconnected display's
|
||||
// arithmetic and the server's agree.
|
||||
estimatedDurationMs:
|
||||
slot.durationOverrideMs ??
|
||||
(providerId === null ? undefined : input.registry.defaultDurationMs(providerId)) ??
|
||||
input.settings.nativeImageDurationMs,
|
||||
id: slot.id,
|
||||
position: slot.position,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderSelection(
|
||||
input: BuildDisplayStateInput,
|
||||
selection: StoredSelection,
|
||||
timeline: StoredTimelineDetail | undefined,
|
||||
audienceKey: string,
|
||||
): Promise<DisplaySelection> {
|
||||
const slot =
|
||||
selection.slotId === null
|
||||
? undefined
|
||||
: timeline?.slots.find((candidate) => candidate.id === selection.slotId);
|
||||
const payload = payloadFor(selection, audienceKey);
|
||||
|
||||
const base: DisplaySelection = {
|
||||
availableUntilMs: selection.availableUntil?.getTime() ?? null,
|
||||
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
|
||||
durationMs: selection.durationMs,
|
||||
id: selection.id,
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
payload,
|
||||
presentation: selection.presentation,
|
||||
providerId: selection.providerId,
|
||||
renderer:
|
||||
selection.providerId === SIGNAGE_NATIVE_PROVIDER_ID ||
|
||||
selection.rendererContributionId === SIGNAGE_NATIVE_RENDERER
|
||||
? { kind: "native-image" }
|
||||
: selection.rendererContributionId === null ||
|
||||
selection.rendererContributionVersion === null
|
||||
? { kind: "unavailable" }
|
||||
: {
|
||||
contributionId: selection.rendererContributionId,
|
||||
contributionVersion: selection.rendererContributionVersion,
|
||||
kind: "contribution",
|
||||
},
|
||||
revision: selection.revision,
|
||||
slotId: selection.slotId,
|
||||
stale: selection.stale,
|
||||
startedAtMs: selection.startedAt?.getTime() ?? null,
|
||||
withdrawn: selection.withdrawn,
|
||||
};
|
||||
|
||||
if (base.renderer.kind !== "native-image") {
|
||||
return base;
|
||||
}
|
||||
const media = await nativeMediaFor(input, payload);
|
||||
const shape = payload as NativePayloadShape | null;
|
||||
const width = typeof shape?.width === "number" ? shape.width : 16;
|
||||
const height = typeof shape?.height === "number" ? shape.height : 9;
|
||||
return {
|
||||
...base,
|
||||
...(media ? { media } : {}),
|
||||
scene: resolveNativeScene({ height, width }, slotPresentationFor(slot, input.settings)),
|
||||
};
|
||||
}
|
||||
|
||||
async function nativeMediaFor(
|
||||
input: BuildDisplayStateInput,
|
||||
payload: unknown,
|
||||
): Promise<DisplaySelection["media"]> {
|
||||
const shape = payload as NativePayloadShape | null;
|
||||
const displayKey = shape?.renditionKeys?.display;
|
||||
const thumbnailKey = shape?.renditionKeys?.thumbnail;
|
||||
if (typeof displayKey !== "string" || typeof thumbnailKey !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const width = typeof shape?.width === "number" ? shape.width : 0;
|
||||
const height = typeof shape?.height === "number" ? shape.height : 0;
|
||||
try {
|
||||
const urls = await createImageDeliveryUrls(input.storage, [
|
||||
{ height, kind: "display", objectKey: displayKey, width },
|
||||
{ height, kind: "thumbnail", objectKey: thumbnailKey, width },
|
||||
]);
|
||||
const display = urls.find((url) => url.kind === "display");
|
||||
const thumbnail = urls.find((url) => url.kind === "thumbnail");
|
||||
if (!display || !thumbnail) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
display: {
|
||||
expiresAtMs: Date.parse(display.expiresAt),
|
||||
height,
|
||||
url: display.url,
|
||||
width,
|
||||
},
|
||||
thumbnail: {
|
||||
expiresAtMs: Date.parse(thumbnail.expiresAt),
|
||||
height,
|
||||
url: thumbnail.url,
|
||||
width,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// Storage being briefly unreachable costs this frame's URLs, not the whole state: a display
|
||||
// that already has the bytes cached keeps rendering, and the next refresh carries fresh ones.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The held image, shaped exactly like a selection so a display's renderer path is the same one. */
|
||||
async function renderStaticHold(
|
||||
input: BuildDisplayStateInput,
|
||||
imageId: string,
|
||||
): Promise<DisplaySelection | null> {
|
||||
const image = await input.store.findImage(imageId);
|
||||
if (image?.status !== "ready") {
|
||||
return null;
|
||||
}
|
||||
const display = image.renditions.find((rendition) => rendition.kind === "display");
|
||||
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
|
||||
const payload = {
|
||||
displayName: image.displayName,
|
||||
height: image.height,
|
||||
imageId: image.id,
|
||||
kind: "native-image",
|
||||
renditionKeys: {
|
||||
display: display?.objectKey ?? image.originalObjectKey,
|
||||
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
|
||||
},
|
||||
width: image.width,
|
||||
};
|
||||
const media = await nativeMediaFor(input, payload);
|
||||
return {
|
||||
availableUntilMs: image.expiresAt?.getTime() ?? null,
|
||||
deadlineAtMs: null,
|
||||
durationMs: 0,
|
||||
id: `static:${image.id}`,
|
||||
itemId: image.id,
|
||||
itemRevision: image.contentSha256.slice(0, 32),
|
||||
...(media ? { media } : {}),
|
||||
payload,
|
||||
presentation: null,
|
||||
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||
renderer: { kind: "native-image" },
|
||||
revision: 1,
|
||||
scene: resolveNativeScene(image, slotPresentationFor(undefined, input.settings)),
|
||||
slotId: null,
|
||||
stale: false,
|
||||
startedAtMs: null,
|
||||
withdrawn: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Signage's one sanitized failure vocabulary.
|
||||
*
|
||||
* Every error that can reach a browser, a log, or an operator carries a **stable code and a fixed
|
||||
* message**, and deliberately nothing else: no caught exception, no stack, no cause, no SQL, no
|
||||
* object key, no storage endpoint, no provider text, no machine path, and no payload. This follows
|
||||
* the same discipline as the platform's own `ModuleStorageError` and migration-runner errors.
|
||||
*
|
||||
* A `detail` is allowed only when it is a value **Signage itself supplied or the caller itself
|
||||
* sent** — an asset id the caller named, a limit Signage declared. It is never something the host,
|
||||
* the database, or another module handed back.
|
||||
*/
|
||||
export const SIGNAGE_ERROR_CODES = [
|
||||
/** The request body, query, or path parameter is not something this route accepts. */
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
/** The named resource does not exist, or is not one this caller may address. */
|
||||
"SIGNAGE_NOT_FOUND",
|
||||
/** The request is well formed but conflicts with current state. */
|
||||
"SIGNAGE_CONFLICT",
|
||||
/** The asset is referenced by a timeline slot, a pinned item, or a live static hold. */
|
||||
"SIGNAGE_ASSET_IN_USE",
|
||||
/** The uploaded bytes are not a still PNG, JPEG, or WebP. */
|
||||
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||
/** The uploaded bytes could not be decoded as an image at all. */
|
||||
"SIGNAGE_IMAGE_MALFORMED",
|
||||
/** Animation is not supported; a signage slide is a still frame. */
|
||||
"SIGNAGE_IMAGE_ANIMATED",
|
||||
/** Multipage input is not supported. */
|
||||
"SIGNAGE_IMAGE_MULTIPAGE",
|
||||
/** The upload exceeded Signage's per-file byte limit. */
|
||||
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||
/** The image decodes to more pixels than Signage will process. */
|
||||
"SIGNAGE_IMAGE_PIXEL_LIMIT",
|
||||
/** Object storage could not be reached, or refused the operation. */
|
||||
"SIGNAGE_STORAGE_UNAVAILABLE",
|
||||
/** Signage could not complete the operation, and has said everything it safely can. */
|
||||
"SIGNAGE_OPERATION_FAILED",
|
||||
] as const;
|
||||
|
||||
export type SignageErrorCode = (typeof SIGNAGE_ERROR_CODES)[number];
|
||||
|
||||
const messages: Readonly<Record<SignageErrorCode, string>> = Object.freeze({
|
||||
SIGNAGE_ASSET_IN_USE: "This image is still referenced and cannot be deleted.",
|
||||
SIGNAGE_CONFLICT: "The request conflicts with the current state.",
|
||||
SIGNAGE_IMAGE_ANIMATED: "Animated images are not supported.",
|
||||
SIGNAGE_IMAGE_MALFORMED: "The uploaded file is not a decodable image.",
|
||||
SIGNAGE_IMAGE_MULTIPAGE: "Multipage images are not supported.",
|
||||
SIGNAGE_IMAGE_PIXEL_LIMIT: "The image exceeds the decoded-pixel limit.",
|
||||
SIGNAGE_IMAGE_TOO_LARGE: "The image exceeds the maximum upload size.",
|
||||
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: "Only still PNG, JPEG, and WebP images are supported.",
|
||||
SIGNAGE_INVALID_REQUEST: "The request is not valid.",
|
||||
SIGNAGE_NOT_FOUND: "The requested resource was not found.",
|
||||
SIGNAGE_OPERATION_FAILED: "The operation could not be completed.",
|
||||
SIGNAGE_STORAGE_UNAVAILABLE: "Object storage is not available.",
|
||||
});
|
||||
|
||||
const statuses: Readonly<Record<SignageErrorCode, number>> = Object.freeze({
|
||||
SIGNAGE_ASSET_IN_USE: 409,
|
||||
SIGNAGE_CONFLICT: 409,
|
||||
SIGNAGE_IMAGE_ANIMATED: 415,
|
||||
SIGNAGE_IMAGE_MALFORMED: 422,
|
||||
SIGNAGE_IMAGE_MULTIPAGE: 415,
|
||||
SIGNAGE_IMAGE_PIXEL_LIMIT: 413,
|
||||
SIGNAGE_IMAGE_TOO_LARGE: 413,
|
||||
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: 415,
|
||||
SIGNAGE_INVALID_REQUEST: 400,
|
||||
SIGNAGE_NOT_FOUND: 404,
|
||||
SIGNAGE_OPERATION_FAILED: 500,
|
||||
SIGNAGE_STORAGE_UNAVAILABLE: 503,
|
||||
});
|
||||
|
||||
/** What a caller may safely be told alongside a code: values the caller sent, or limits Signage
|
||||
* declared. Never a host value, a driver message, or another module's text. */
|
||||
export type SignageErrorDetail = Readonly<Record<string, number | string | null>>;
|
||||
|
||||
export class SignageError extends Error {
|
||||
readonly code: SignageErrorCode;
|
||||
readonly status: number;
|
||||
readonly detail?: SignageErrorDetail;
|
||||
|
||||
constructor(code: SignageErrorCode, detail?: SignageErrorDetail) {
|
||||
super(messages[code]);
|
||||
this.name = "SignageError";
|
||||
this.code = code;
|
||||
this.status = statuses[code];
|
||||
if (detail !== undefined) {
|
||||
this.detail = Object.freeze({ ...detail });
|
||||
}
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function signageErrorResponse(error: SignageError): Response {
|
||||
return Response.json(
|
||||
{ code: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one boundary every Signage route handler runs inside.
|
||||
*
|
||||
* A `SignageError` becomes its own sanitized response. **Anything else becomes
|
||||
* `SIGNAGE_OPERATION_FAILED` with nothing of the caught value in it** — a database error, a storage
|
||||
* failure, a bug, and a provider's own exception all look identical from outside, which is the
|
||||
* point. The platform would sanitize an uncaught throw too; catching it here is how Signage keeps a
|
||||
* useful code instead of a generic one.
|
||||
*/
|
||||
export async function signageBoundary(run: () => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (error instanceof SignageError) {
|
||||
return signageErrorResponse(error);
|
||||
}
|
||||
return signageErrorResponse(new SignageError("SIGNAGE_OPERATION_FAILED"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Request-shaping helpers, and — more importantly — parameter validation.
|
||||
*
|
||||
* `context.params` holds the router's **decoded** path values. Decoding is not validation, and the
|
||||
* platform deliberately supplies no generic parameter grammar: these are Signage's domain types, so
|
||||
* validating them is Signage's job, before any database, storage, or identifier use.
|
||||
*/
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
||||
|
||||
export function requireUuidParam(context: ModuleRouteHandlerContext, name: string): string {
|
||||
const value = context.params[name];
|
||||
if (value === undefined || !UUID.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: name });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isUuid(value: unknown): value is string {
|
||||
return typeof value === "string" && UUID.test(value);
|
||||
}
|
||||
|
||||
/** Reads a JSON request body a route declared a buffered allowance for. A body that is absent,
|
||||
* oversized for its own declared shape, or not a JSON object is refused with Signage's own code
|
||||
* rather than a parser's message. */
|
||||
export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await request.json();
|
||||
} catch {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function optionalString(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
options: { readonly maxLength: number },
|
||||
): string | undefined {
|
||||
const value = body[field];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim().length === 0 || value.length > options.maxLength) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function requiredString(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
options: { readonly maxLength: number },
|
||||
): string {
|
||||
const value = optionalString(body, field, options);
|
||||
if (value === undefined) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** An absolute instant, or `null` to clear one. Signage never accepts an ambiguous local timestamp:
|
||||
* a value must parse as an absolute instant or be refused. */
|
||||
export function optionalInstant(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
): Date | null | undefined {
|
||||
const value = body[field];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function optionalInteger(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
options: { readonly minimum: number; readonly maximum: number },
|
||||
): number | null | undefined {
|
||||
const value = body[field];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < options.minimum ||
|
||||
value > options.maximum
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalBoolean(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
): boolean | null | undefined {
|
||||
const value = body[field];
|
||||
if (value === undefined || value === null) {
|
||||
return value as null | undefined;
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The bounds Signage's native image ingest enforces, in one place.
|
||||
*
|
||||
* These are Signage's own product limits, deliberately inside the platform's. The platform's
|
||||
* absolute request-body ceiling is 32 MiB; Signage declares 25 MiB and refuses anything larger
|
||||
* itself rather than relying on the platform's refusal. Two limits that agree are two chances to
|
||||
* catch a mistake, and a module that only had the platform's would silently change its own product
|
||||
* behaviour if the platform's ever moved.
|
||||
*
|
||||
* The upload route carries **raw image bytes**, not a multipart envelope, so the declared allowance
|
||||
* is exactly the image limit. One request is one file; nothing in Signage can express an aggregate
|
||||
* upload, and the filename and display name travel in the query string.
|
||||
*/
|
||||
|
||||
/** The largest image file an operator may upload. */
|
||||
export const SIGNAGE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The decoded-pixel budget.
|
||||
*
|
||||
* File size does not bound decoding: a few hundred kilobytes of PNG can decode to gigabytes of
|
||||
* pixels, which is the actual shape of a decompression bomb. 40 million pixels is roughly a 7700 ×
|
||||
* 5200 photograph — far beyond any signage slide, and far below anything that threatens the process.
|
||||
*/
|
||||
export const SIGNAGE_IMAGE_MAX_PIXELS = 40_000_000;
|
||||
|
||||
/** How many files the dashboard offers to queue at once. One request each, always. */
|
||||
export const SIGNAGE_UPLOAD_BATCH_MAX_FILES = 20;
|
||||
|
||||
/** How many of those requests the dashboard runs concurrently. Bounded so a batch cannot pin the
|
||||
* API's memory with several simultaneous full-size images, and so the dashboard stays responsive. */
|
||||
export const SIGNAGE_UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
export const SIGNAGE_IMAGE_DISPLAY_RENDITION = Object.freeze({
|
||||
kind: "display" as const,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
quality: 82,
|
||||
});
|
||||
|
||||
export const SIGNAGE_IMAGE_THUMBNAIL_RENDITION = Object.freeze({
|
||||
kind: "thumbnail" as const,
|
||||
maxWidth: 480,
|
||||
maxHeight: 270,
|
||||
quality: 72,
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import sharp, { type Metadata, type OutputInfo } from "sharp";
|
||||
import { SignageError } from "../errors.js";
|
||||
import {
|
||||
SIGNAGE_IMAGE_DISPLAY_RENDITION,
|
||||
SIGNAGE_IMAGE_MAX_PIXELS,
|
||||
SIGNAGE_IMAGE_THUMBNAIL_RENDITION,
|
||||
} from "./limits.js";
|
||||
|
||||
/**
|
||||
* Signage's native image pipeline.
|
||||
*
|
||||
* Three properties matter more than the code:
|
||||
*
|
||||
* 1. **Format comes from the bytes.** A filename and a declared content type are attacker-controlled
|
||||
* and routinely wrong even when they are not; the decoder decides what this is.
|
||||
* 2. **The original is never rewritten.** It is a church's file, archived exactly as uploaded. Only
|
||||
* the two renditions Signage generates are transformed.
|
||||
* 3. **Nothing about presentation is baked in.** Background, blur, darkening, and shadow are
|
||||
* resolved at render time from a slot's own settings, so changing them is a database update
|
||||
* rather than a re-encode (ADR 0007).
|
||||
*/
|
||||
|
||||
const SUPPORTED_FORMATS: ReadonlySet<string> = new Set(["jpeg", "png", "webp"]);
|
||||
|
||||
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
});
|
||||
|
||||
const FILE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
jpeg: "jpg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
});
|
||||
|
||||
export interface SignageImageRendition {
|
||||
readonly kind: "display" | "thumbnail";
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType: string;
|
||||
readonly contentSha256: string;
|
||||
readonly byteSize: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface SignageProcessedImage {
|
||||
readonly contentSha256: string;
|
||||
readonly byteSize: number;
|
||||
readonly format: string;
|
||||
readonly contentType: string;
|
||||
readonly fileExtension: string;
|
||||
/** How the original should appear once its own orientation is applied. */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
/** What is actually encoded in the archived bytes. */
|
||||
readonly encodedWidth: number;
|
||||
readonly encodedHeight: number;
|
||||
readonly orientation: number | null;
|
||||
readonly hasAlpha: boolean;
|
||||
readonly renditions: readonly SignageImageRendition[];
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
/** Sharp reports a pixel-limit breach and a decode failure through the same exception type. The
|
||||
* message is inspected here and then **discarded** — nothing from the decoder reaches a caller. */
|
||||
function decodeFailure(error: unknown): SignageError {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/pixel limit/iu.test(message)) {
|
||||
return new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||
}
|
||||
return new SignageError("SIGNAGE_IMAGE_MALFORMED");
|
||||
}
|
||||
|
||||
async function readMetadata(bytes: Uint8Array): Promise<Metadata> {
|
||||
try {
|
||||
return await sharp(bytes, {
|
||||
animated: true,
|
||||
failOn: "error",
|
||||
limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS,
|
||||
}).metadata();
|
||||
} catch (error) {
|
||||
throw decodeFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupported(metadata: Metadata): void {
|
||||
const pages = metadata.pages ?? 1;
|
||||
if (pages > 1) {
|
||||
const animated =
|
||||
metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0);
|
||||
throw new SignageError(animated ? "SIGNAGE_IMAGE_ANIMATED" : "SIGNAGE_IMAGE_MULTIPAGE", {
|
||||
pages,
|
||||
});
|
||||
}
|
||||
if (metadata.format === undefined || !SUPPORTED_FORMATS.has(metadata.format)) {
|
||||
// The detected format is echoed because the caller supplied the bytes it came from. Nothing
|
||||
// about the host, the decoder, or the process is in this response.
|
||||
throw new SignageError("SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", {
|
||||
detectedFormat: metadata.format ?? null,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(metadata.width) ||
|
||||
(metadata.width ?? 0) < 1 ||
|
||||
!Number.isSafeInteger(metadata.height) ||
|
||||
(metadata.height ?? 0) < 1
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_IMAGE_MALFORMED");
|
||||
}
|
||||
if ((metadata.width ?? 0) * (metadata.height ?? 0) > SIGNAGE_IMAGE_MAX_PIXELS) {
|
||||
throw new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||
}
|
||||
}
|
||||
|
||||
async function renderRendition(
|
||||
bytes: Uint8Array,
|
||||
target: {
|
||||
readonly kind: "display" | "thumbnail";
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
quality: number;
|
||||
},
|
||||
): Promise<SignageImageRendition> {
|
||||
let output: { data: Buffer; info: OutputInfo };
|
||||
try {
|
||||
output = await sharp(bytes, { failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS })
|
||||
// Honour the original's own orientation, so a phone photograph is not shown on its side.
|
||||
.autoOrient()
|
||||
// Transparency becomes black rather than white: a signage display's ground is black, and a
|
||||
// white flash behind a logo is the artefact this avoids.
|
||||
.flatten({ background: { b: 0, g: 0, r: 0 } })
|
||||
// `inside` never crops, and `withoutEnlargement` never upscales — a small image stays small
|
||||
// rather than being blown up into softness.
|
||||
.resize({
|
||||
fit: "inside",
|
||||
height: target.maxHeight,
|
||||
width: target.maxWidth,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toColourspace("srgb")
|
||||
// No `withMetadata()`: EXIF, GPS coordinates, and camera serial numbers are stripped from
|
||||
// anything Signage publishes. The original keeps them; the rendition a browser fetches
|
||||
// does not.
|
||||
.webp({ effort: 4, quality: target.quality })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
} catch (error) {
|
||||
throw decodeFailure(error);
|
||||
}
|
||||
const data = new Uint8Array(output.data);
|
||||
return {
|
||||
byteSize: data.byteLength,
|
||||
bytes: data,
|
||||
contentSha256: sha256Hex(data),
|
||||
contentType: CONTENT_TYPES.webp as string,
|
||||
height: output.info.height,
|
||||
kind: target.kind,
|
||||
width: output.info.width,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the uploaded bytes and produces both renditions, or throws one of Signage's own
|
||||
* sanitized codes. Deterministic: the same input always yields the same output bytes, which is what
|
||||
* makes a retry idempotent.
|
||||
*/
|
||||
export async function processSignageImage(bytes: Uint8Array): Promise<SignageProcessedImage> {
|
||||
const metadata = await readMetadata(bytes);
|
||||
assertSupported(metadata);
|
||||
|
||||
const format = metadata.format as string;
|
||||
const oriented = metadata.autoOrient ?? {
|
||||
height: metadata.height as number,
|
||||
width: metadata.width as number,
|
||||
};
|
||||
const [display, thumbnail] = await Promise.all([
|
||||
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION),
|
||||
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION),
|
||||
]);
|
||||
|
||||
return {
|
||||
byteSize: bytes.byteLength,
|
||||
contentSha256: sha256Hex(bytes),
|
||||
contentType: CONTENT_TYPES[format] as string,
|
||||
encodedHeight: metadata.height as number,
|
||||
encodedWidth: metadata.width as number,
|
||||
fileExtension: FILE_EXTENSIONS[format] as string,
|
||||
format,
|
||||
hasAlpha: Boolean(metadata.hasAlpha),
|
||||
height: oriented.height,
|
||||
orientation: metadata.orientation ?? null,
|
||||
renditions: [display, thumbnail],
|
||||
width: oriented.width,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
type ModuleStorage,
|
||||
type ModuleStorageBucket,
|
||||
ModuleStorageError,
|
||||
} from "@ekklesia/module-sdk";
|
||||
import { SignageError } from "../errors.js";
|
||||
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
|
||||
import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js";
|
||||
import { processSignageImage, type SignageProcessedImage } from "./processing.js";
|
||||
|
||||
/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential —
|
||||
* which is what lets the same code run under Ekklesia and under a future standalone host. */
|
||||
export const SIGNAGE_IMAGE_BUCKET = "native-images";
|
||||
|
||||
export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
|
||||
return storage.bucket(SIGNAGE_IMAGE_BUCKET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Object keys are scoped to the asset they belong to.
|
||||
*
|
||||
* Until the asset row commits these are **temporary**: nothing references them, nothing can select
|
||||
* them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a
|
||||
* throwaway upload id is what makes a successful upload cost exactly one write of each object
|
||||
* instead of two — the commit is the database row, not a second copy of 25 MiB.
|
||||
*/
|
||||
export function originalObjectKey(assetId: string, extension: string): string {
|
||||
return `assets/${assetId}/original.${extension}`;
|
||||
}
|
||||
|
||||
export function renditionObjectKey(assetId: string, kind: string): string {
|
||||
return `assets/${assetId}/${kind}.webp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every storage failure becomes one of Signage's own codes.
|
||||
*
|
||||
* `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it
|
||||
* further keeps even *which* storage operation failed out of Signage's responses, because that is
|
||||
* not a caller's business.
|
||||
*/
|
||||
function storageFailure(error: unknown): SignageError {
|
||||
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") {
|
||||
return new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
|
||||
}
|
||||
|
||||
export interface IngestNativeImageInput {
|
||||
readonly store: SignageStore;
|
||||
readonly storage: ModuleStorage;
|
||||
readonly bytes: Uint8Array;
|
||||
readonly filename: string;
|
||||
readonly displayName: string;
|
||||
readonly expiresAt: Date | null;
|
||||
/** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */
|
||||
readonly newId?: () => string;
|
||||
}
|
||||
|
||||
export interface IngestNativeImageResult {
|
||||
readonly image: StoredImage;
|
||||
/** True when identical bytes were already stored, so nothing new was written anywhere. */
|
||||
readonly deduplicated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests one native image.
|
||||
*
|
||||
* The order **is** the contract (ADR 0007):
|
||||
*
|
||||
* 1. The caller has already read the whole request body under Signage's own bound.
|
||||
* 2. Decode and validate. An invalid image has cost zero storage writes and zero rows.
|
||||
* 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes.
|
||||
* 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them.
|
||||
* 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist,
|
||||
* so a partial write can never be promoted into something playback could select.
|
||||
* 6. On any failure after step 4, delete every object this attempt wrote.
|
||||
*/
|
||||
export async function ingestNativeImage(
|
||||
input: IngestNativeImageInput,
|
||||
): Promise<IngestNativeImageResult> {
|
||||
if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) {
|
||||
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES });
|
||||
}
|
||||
|
||||
const processed: SignageProcessedImage = await processSignageImage(input.bytes);
|
||||
|
||||
const existing = await input.store.findReadyImageByContentHash(processed.contentSha256);
|
||||
if (existing) {
|
||||
return { deduplicated: true, image: existing };
|
||||
}
|
||||
|
||||
const bucket = nativeImageBucket(input.storage);
|
||||
const assetId = (input.newId ?? randomUUID)();
|
||||
const original = originalObjectKey(assetId, processed.fileExtension);
|
||||
const written: string[] = [];
|
||||
|
||||
try {
|
||||
await bucket.put(original, input.bytes, { contentType: processed.contentType });
|
||||
written.push(original);
|
||||
|
||||
const renditions = [];
|
||||
for (const rendition of processed.renditions) {
|
||||
const key = renditionObjectKey(assetId, rendition.kind);
|
||||
await bucket.put(key, rendition.bytes, { contentType: rendition.contentType });
|
||||
written.push(key);
|
||||
renditions.push({
|
||||
byteSize: rendition.byteSize,
|
||||
contentSha256: rendition.contentSha256,
|
||||
contentType: rendition.contentType,
|
||||
height: rendition.height,
|
||||
kind: rendition.kind,
|
||||
objectKey: key,
|
||||
width: rendition.width,
|
||||
});
|
||||
}
|
||||
|
||||
await input.store.insertImage({
|
||||
contentSha256: processed.contentSha256,
|
||||
displayName: input.displayName,
|
||||
encodedHeight: processed.encodedHeight,
|
||||
encodedWidth: processed.encodedWidth,
|
||||
expiresAt: input.expiresAt,
|
||||
hasAlpha: processed.hasAlpha,
|
||||
height: processed.height,
|
||||
id: assetId,
|
||||
orientation: processed.orientation,
|
||||
originalByteSize: processed.byteSize,
|
||||
originalContentType: processed.contentType,
|
||||
originalFilename: input.filename,
|
||||
originalObjectKey: original,
|
||||
renditions,
|
||||
status: "ready",
|
||||
width: processed.width,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing this attempt wrote is referenced by anything, so removing all of it is always safe.
|
||||
await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined)));
|
||||
|
||||
if (error instanceof SignageStoreConflict) {
|
||||
// Another request committed the identical bytes while this one was writing. For the caller
|
||||
// that is a success, not a failure: the image they uploaded is stored.
|
||||
const raced = await input.store.findReadyImageByContentHash(processed.contentSha256);
|
||||
if (raced) {
|
||||
return { deduplicated: true, image: raced };
|
||||
}
|
||||
throw new SignageError("SIGNAGE_CONFLICT");
|
||||
}
|
||||
if (error instanceof SignageError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof ModuleStorageError) {
|
||||
throw storageFailure(error);
|
||||
}
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
|
||||
const image = await input.store.findImage(assetId);
|
||||
if (!image) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return { deduplicated: false, image };
|
||||
}
|
||||
|
||||
export interface SignageImageDelivery {
|
||||
readonly kind: string;
|
||||
readonly url: string;
|
||||
readonly expiresAt: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh, short-lived delivery URLs for one image's renditions.
|
||||
*
|
||||
* Requested when something is about to be delivered, and then forgotten. Signage persists the
|
||||
* logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one
|
||||
* in a table would be storing a credential that stops working.
|
||||
*/
|
||||
export async function createImageDeliveryUrls(
|
||||
storage: ModuleStorage,
|
||||
renditions: readonly {
|
||||
readonly kind: string;
|
||||
readonly objectKey: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}[],
|
||||
): Promise<readonly SignageImageDelivery[]> {
|
||||
const bucket = nativeImageBucket(storage);
|
||||
try {
|
||||
return await Promise.all(
|
||||
renditions.map(async (rendition) => {
|
||||
const url = await bucket.createReadUrl(rendition.objectKey);
|
||||
return {
|
||||
expiresAt: url.expiresAt.toISOString(),
|
||||
height: rendition.height,
|
||||
kind: rendition.kind,
|
||||
url: url.url,
|
||||
width: rendition.width,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw storageFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNativeImage(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly assetId: string;
|
||||
readonly displayName?: string | undefined;
|
||||
readonly expiresAt?: Date | null | undefined;
|
||||
}): Promise<StoredImage> {
|
||||
const existing = await input.store.findImage(input.assetId);
|
||||
if (!existing) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const changes: { displayName?: string; expiresAt?: Date | null } = {};
|
||||
if (input.displayName !== undefined && input.displayName !== existing.displayName) {
|
||||
changes.displayName = input.displayName;
|
||||
}
|
||||
if (input.expiresAt !== undefined) {
|
||||
const current = existing.expiresAt?.getTime() ?? null;
|
||||
const next = input.expiresAt?.getTime() ?? null;
|
||||
if (current !== next) {
|
||||
changes.expiresAt = input.expiresAt;
|
||||
}
|
||||
}
|
||||
// An exact no-op writes nothing at all.
|
||||
if (Object.keys(changes).length === 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await input.store.updateImage(input.assetId, changes);
|
||||
const updated = await input.store.findImage(input.assetId);
|
||||
if (!updated) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image, and refuses while something still refers to it.
|
||||
*
|
||||
* A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service.
|
||||
* Refusing with a count of what refers to it is something an operator can act on; a silent cascade
|
||||
* is not.
|
||||
*/
|
||||
export async function deleteNativeImage(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly storage: ModuleStorage;
|
||||
readonly assetId: string;
|
||||
}): Promise<void> {
|
||||
const existing = await input.store.findImage(input.assetId);
|
||||
if (!existing) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const references = await input.store.countImageReferences(input.assetId);
|
||||
if (references.slots > 0 || references.staticHolds > 0) {
|
||||
throw new SignageError("SIGNAGE_ASSET_IN_USE", {
|
||||
heldByChannelGroups: references.staticHolds,
|
||||
referencedBySlots: references.slots,
|
||||
});
|
||||
}
|
||||
|
||||
// The row goes first: an asset nothing can select is the state that matters, and an object left
|
||||
// behind by a storage outage is inert rather than dangerous.
|
||||
await input.store.deleteImage(input.assetId);
|
||||
const bucket = nativeImageBucket(input.storage);
|
||||
const keys = [
|
||||
existing.originalObjectKey,
|
||||
...existing.renditions.map((rendition) => rendition.objectKey),
|
||||
];
|
||||
await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined)));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SignageError } from "../errors.js";
|
||||
|
||||
/**
|
||||
* Reads a streamed request body under a hard byte bound.
|
||||
*
|
||||
* The upload route runs in the platform's `streamed` body mode, which means the platform counts
|
||||
* bytes **as they are consumed** and never materializes the body itself. Signage counts them too,
|
||||
* and stops at its own smaller limit — so an oversized upload is refused by Signage's own code with
|
||||
* Signage's own message, rather than only by the platform's later `413`.
|
||||
*
|
||||
* The bytes are accumulated because an image cannot be validated, hashed, or resized from a partial
|
||||
* stream: a decoder needs the whole file, and the decoded-pixel budget can only be checked once the
|
||||
* header is present. The bound is what makes that safe — the peak is one image, never a request of
|
||||
* unknown size, and never more than {@link maxBytes}.
|
||||
*
|
||||
* The source is cancelled as soon as the bound is passed, so a client sending more stops being read
|
||||
* rather than being read and discarded.
|
||||
*/
|
||||
export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array> {
|
||||
const declared = request.headers.get("content-length");
|
||||
if (declared !== null) {
|
||||
const length = Number(declared);
|
||||
// A declared length over the limit is refused before a byte is read. A missing or lying one
|
||||
// bounds nothing, which is exactly why the running total below is the real check.
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||
}
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||
}
|
||||
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SignageError) {
|
||||
throw error;
|
||||
}
|
||||
// The platform errors the stream when *its* limit is passed, and cancels the source. Nothing
|
||||
// about that error reaches a caller: it becomes Signage's own bounded code.
|
||||
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createSignageModuleRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* Signage's compiled server entrypoint.
|
||||
*
|
||||
* `id` and `version` are build-artifact identity markers and must match `ekklesia.module.json`
|
||||
* exactly; Core verifies both before this module is allowed to register anything (ADR 0006). The
|
||||
* runtime itself is built by a factory (`./runtime.ts`) so a test can construct a fresh one rather
|
||||
* than exercising a shared singleton's leftovers.
|
||||
*/
|
||||
export default createSignageModuleRuntime().definition;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PlaybackSnapshot, SignagePlaybackService } from "./service.js";
|
||||
|
||||
/**
|
||||
* `signage.playback` — sanitized playback state and commands for another module.
|
||||
*
|
||||
* The intended consumer is something like an Ekklesia Companion: a module that gives a volunteer at
|
||||
* the back of a room a way to hold a slide or black out a screen. What it gets is exactly the
|
||||
* operations an operator has in the dashboard, over the same authoritative service, with the same
|
||||
* serialization.
|
||||
*
|
||||
* What it deliberately is **not**: a transport. There is no topic, no broker, no webhook, no
|
||||
* integration vocabulary of any kind in this contract — a consumer that wanted to bridge these
|
||||
* commands onto something else would do that in its own module, where that decision belongs.
|
||||
*
|
||||
* What it deliberately does not carry: a Core credential, a session, a device identity, a provider
|
||||
* payload, or any private host internal. State here is identity, timing, and revisions.
|
||||
*/
|
||||
export const SIGNAGE_PLAYBACK_CAPABILITY_VERSION = "1.0.0";
|
||||
|
||||
/** One channel group's state, exactly as a display sees it. */
|
||||
export type SignagePlaybackState = PlaybackSnapshot;
|
||||
|
||||
export interface SignagePlaybackCapability {
|
||||
readonly contractVersion: string;
|
||||
/** Every channel group and its current authoritative state. */
|
||||
listGroups(): Promise<readonly SignagePlaybackState[]>;
|
||||
readGroup(groupId: string): Promise<SignagePlaybackState>;
|
||||
/** Immediately, or queued for the next safe slot boundary. */
|
||||
activate(
|
||||
groupId: string,
|
||||
timelineId: string,
|
||||
when: "now" | "next-boundary",
|
||||
): Promise<SignagePlaybackState>;
|
||||
pause(groupId: string): Promise<SignagePlaybackState>;
|
||||
resume(groupId: string): Promise<SignagePlaybackState>;
|
||||
next(groupId: string): Promise<SignagePlaybackState>;
|
||||
previous(groupId: string): Promise<SignagePlaybackState>;
|
||||
setBlackout(groupId: string, on: boolean): Promise<SignagePlaybackState>;
|
||||
/** `null` clears the hold and continues the slide underneath from its preserved remainder. */
|
||||
setStaticHold(groupId: string, imageId: string | null): Promise<SignagePlaybackState>;
|
||||
/** Re-resolves provider availability and the current selection without disturbing the frame. */
|
||||
reload(groupId: string): Promise<SignagePlaybackState>;
|
||||
}
|
||||
|
||||
export function createPlaybackCapability(
|
||||
playback: () => SignagePlaybackService,
|
||||
listGroupIds: () => Promise<readonly string[]>,
|
||||
): SignagePlaybackCapability {
|
||||
const capability: SignagePlaybackCapability = {
|
||||
activate: (groupId: string, timelineId: string, when: "now" | "next-boundary") =>
|
||||
playback().activate(groupId, timelineId, when),
|
||||
contractVersion: SIGNAGE_PLAYBACK_CAPABILITY_VERSION,
|
||||
async listGroups(): Promise<readonly SignagePlaybackState[]> {
|
||||
const ids = await listGroupIds();
|
||||
return Promise.all(ids.map((groupId) => playback().snapshot(groupId)));
|
||||
},
|
||||
next: (groupId: string) => playback().next(groupId),
|
||||
pause: (groupId: string) => playback().pause(groupId),
|
||||
previous: (groupId: string) => playback().previous(groupId),
|
||||
readGroup: (groupId: string) => playback().snapshot(groupId),
|
||||
reload: (groupId: string) => playback().reload(groupId),
|
||||
resume: (groupId: string) => playback().resume(groupId),
|
||||
setBlackout: (groupId: string, on: boolean) => playback().setBlackout(groupId, on),
|
||||
setStaticHold: (groupId: string, imageId: string | null) =>
|
||||
playback().setStaticHold(groupId, imageId),
|
||||
};
|
||||
return Object.freeze(capability);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The `signage.content-providers` contract, version 1 (ADR 0002).
|
||||
*
|
||||
* This is the seam every future content module reaches Signage through: Announcements, Scripture,
|
||||
* Trivia, and anything else a church installs. It is the one contract in this repository with
|
||||
* implementations Signage's authors will never see, so it is written to be implementable from the
|
||||
* documentation alone.
|
||||
*
|
||||
* ## A provider does not import this file
|
||||
*
|
||||
* Modules are independently installed peers, and `pnpm boundaries` refuses a module that imports
|
||||
* another module. A provider therefore resolves the capability, receives an `unknown`
|
||||
* implementation, and casts it to **its own local copy** of these declarations — which is what keeps
|
||||
* the two modules independently versioned and separately releasable. `docs/provider-contract.md`
|
||||
* publishes the shapes for exactly that purpose.
|
||||
*
|
||||
* ## Signage validates what it receives, always
|
||||
*
|
||||
* Everything crossing this boundary is validated before it is believed: registrations, resolutions,
|
||||
* payloads, and durations. A provider is trusted code in the same process, so this is not a sandbox
|
||||
* — it is the difference between one module's bug staying one module's bug and a malformed value
|
||||
* reaching a television.
|
||||
*/
|
||||
|
||||
/** The contract version Signage implements, and the only one it accepts a registration for. */
|
||||
export const SIGNAGE_PROVIDER_CONTRACT_VERSION = "1.0.0";
|
||||
|
||||
/** Major-version compatibility: a provider written against `1.x` works, `2.x` does not. Stated as a
|
||||
* predicate rather than a range string so no semantic-version parser has to cross this boundary. */
|
||||
export function isSupportedProviderContractVersion(version: string): boolean {
|
||||
return /^1\.\d+\.\d+$/u.test(version);
|
||||
}
|
||||
|
||||
export type SignageJsonValue =
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| null
|
||||
| readonly SignageJsonValue[]
|
||||
| { readonly [key: string]: SignageJsonValue };
|
||||
|
||||
/** The audience key of the base channel every group has. A provider that offers nothing
|
||||
* audience-specific returns exactly one variant, under this key. */
|
||||
export const SIGNAGE_DEFAULT_AUDIENCE_KEY = "default";
|
||||
|
||||
/** One of a provider's own sources, as an operator picks it in the timeline editor. */
|
||||
export interface SignageProviderSourceDescriptor {
|
||||
/** The provider's own identity for this source. Opaque to Signage. */
|
||||
readonly id: string;
|
||||
/** What an operator sees. Signage stores a copy so the editor still reads sensibly when the
|
||||
* provider is temporarily not running. */
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/** One specific item, so an operator can pin it into a slot. */
|
||||
export interface SignageProviderItemDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
/** When this item stops being available, as an absolute ISO 8601 instant. */
|
||||
readonly availableUntil?: string | null;
|
||||
}
|
||||
|
||||
/** One audience's rendering of one logical selection. */
|
||||
export interface SignageProviderVariant {
|
||||
/** `null` — or {@link SIGNAGE_DEFAULT_AUDIENCE_KEY} — is the default every channel falls back to. */
|
||||
readonly audienceKey: string | null;
|
||||
/** Structured, finite JSON. Signage validates that it *is* JSON and never what it means. */
|
||||
readonly payload: SignageJsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a provider hands back for one slot occurrence: **one logical item**, never one per channel.
|
||||
*
|
||||
* Resolving once and deriving variants is what keeps every channel in a group on the same slide with
|
||||
* the same deadline, and what makes an audience a rendering rather than a second program (ADR 0004).
|
||||
*/
|
||||
export interface SignageProviderResolution {
|
||||
/** The provider's own stable identity for this content. */
|
||||
readonly itemId: string;
|
||||
/** Changes whenever the payload changes and the item stays the same thing. */
|
||||
readonly itemRevision: string;
|
||||
/** Overrides this provider's default duration for this one item. */
|
||||
readonly durationMs?: number;
|
||||
/** When this selection stops being valid, as an absolute ISO 8601 instant. */
|
||||
readonly availableUntil?: string | null;
|
||||
/** This item is explicitly gone. Signage removes it at the next safe reconciliation boundary
|
||||
* rather than immediately, so a screen does not cut mid-slide. */
|
||||
readonly withdrawn?: boolean;
|
||||
/** Presentation values the provider supplies to its **own** renderer. Opaque to Signage, which
|
||||
* never interprets, merges, or overrides them. */
|
||||
readonly presentation?: SignageJsonValue;
|
||||
/** At least the default variant. */
|
||||
readonly variants: readonly SignageProviderVariant[];
|
||||
}
|
||||
|
||||
/** What Signage tells a provider when it asks for the next item. Deliberately nothing more: a
|
||||
* provider is already running in this process, and what it needs from Signage is *when*. */
|
||||
export interface SignageProviderResolveRequest {
|
||||
/** Stable and opaque. Every slot referencing one source instance passes the same value, which is
|
||||
* what makes five Announcement slots one rotation rather than five (ADR 0003). */
|
||||
readonly selectionScopeId: string;
|
||||
/** Which of the provider's own sources is being asked. */
|
||||
readonly sourceId: string;
|
||||
readonly timelineId: string;
|
||||
readonly channelGroupId: string;
|
||||
/** Every audience channel in the group, in a stable order. */
|
||||
readonly audienceKeys: readonly string[];
|
||||
/** The instant being resolved for, as an absolute ISO 8601 instant. */
|
||||
readonly at: string;
|
||||
/** The installation's IANA zone, for a provider whose availability is calendar-shaped. */
|
||||
readonly timezone: string;
|
||||
}
|
||||
|
||||
/** What Signage tells a provider when it wants one already-known item resolved again — a pinned
|
||||
* slot's item, or a live refresh of what is currently on screen. */
|
||||
export interface SignageProviderRefreshRequest
|
||||
extends Omit<SignageProviderResolveRequest, "sourceId" | "selectionScopeId"> {
|
||||
readonly itemId: string;
|
||||
/** Present when the item came from a source; absent for a pinned item. */
|
||||
readonly sourceId?: string;
|
||||
readonly selectionScopeId?: string;
|
||||
}
|
||||
|
||||
export interface SignageProviderDiscoveryRequest {
|
||||
readonly at: string;
|
||||
readonly timezone: string;
|
||||
}
|
||||
|
||||
/** The renderer a provider's content is drawn by: a browser contribution the platform publishes
|
||||
* against Signage's `signage.slide-renderer` capability (ADR 0041). Signage never imports it; its
|
||||
* display asks the host to mount it by id. */
|
||||
export interface SignageProviderRenderer {
|
||||
readonly contributionId: string;
|
||||
readonly contributionVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a provider module passes to `register`.
|
||||
*
|
||||
* Every function may reject or throw. Signage bounds each call with a deadline, catches everything,
|
||||
* and converts the outcome into one of three answers — nothing available, explicitly withdrawn, or
|
||||
* temporarily failed — which is the distinction the whole failure model rests on (ADR 0002).
|
||||
*/
|
||||
export interface SignageProviderRegistration {
|
||||
readonly providerId: string;
|
||||
readonly providerVersion: string;
|
||||
/** Which version of this contract the provider was written against. */
|
||||
readonly contractVersion: string;
|
||||
readonly renderer: SignageProviderRenderer;
|
||||
/** How long this provider's content is shown when no slot overrides it. */
|
||||
readonly defaultDurationMs: number;
|
||||
listSources(
|
||||
request: SignageProviderDiscoveryRequest,
|
||||
):
|
||||
| Promise<readonly SignageProviderSourceDescriptor[]>
|
||||
| readonly SignageProviderSourceDescriptor[];
|
||||
/** Optional. Without it, an operator cannot pin one of this provider's items. */
|
||||
listItems?(
|
||||
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||
): Promise<readonly SignageProviderItemDescriptor[]> | readonly SignageProviderItemDescriptor[];
|
||||
/** **The only call that advances anything.** `null` means nothing is available for this source
|
||||
* right now, and Signage skips the slot immediately with no error and no slate. */
|
||||
resolveNext(
|
||||
request: SignageProviderResolveRequest,
|
||||
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
|
||||
/** Re-resolves one already-known item without advancing. */
|
||||
refresh(
|
||||
request: SignageProviderRefreshRequest,
|
||||
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
|
||||
}
|
||||
|
||||
/** What a provider may tell Signage has changed. A **notification**, never a command: Signage
|
||||
* decides what to refresh and when, and nothing here can skip a slot or move a deadline. */
|
||||
export interface SignageProviderInvalidation {
|
||||
/** Limits the notification to one of the provider's sources. */
|
||||
readonly sourceId?: string;
|
||||
/** Limits it to one item — a cancelled event, a changed countdown. */
|
||||
readonly itemId?: string;
|
||||
}
|
||||
|
||||
/** What `register` returns. Held by the provider module and released in its own `stop` hook. */
|
||||
export interface SignageProviderHandle {
|
||||
/** Idempotent. Unregistering an already-unregistered provider is a no-op, which is what makes a
|
||||
* rollback after a partially completed `start` safe. */
|
||||
unregister(): void;
|
||||
/** Notifies Signage that something it already resolved may have changed. Never throws for a
|
||||
* provider that has been unregistered — a late notification from a module being torn down is
|
||||
* ordinary, not an error. */
|
||||
invalidate(change?: SignageProviderInvalidation): void;
|
||||
}
|
||||
|
||||
/** The capability implementation Signage provides as `signage.content-providers`. */
|
||||
export interface SignageContentProviderCapability {
|
||||
readonly contractVersion: string;
|
||||
/** Takes `unknown` deliberately: the caller is another module with its own copy of these types,
|
||||
* and Signage validates the value rather than trusting a structural type it cannot enforce across
|
||||
* a module boundary. */
|
||||
register(registration: unknown): SignageProviderHandle;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_RENDERER } from "../../shared/native-images.js";
|
||||
import type { SignageStore, StoredImage } from "../store/types.js";
|
||||
import type {
|
||||
SignageProviderDiscoveryRequest,
|
||||
SignageProviderItemDescriptor,
|
||||
SignageProviderRefreshRequest,
|
||||
SignageProviderRegistration,
|
||||
SignageProviderResolution,
|
||||
SignageProviderResolveRequest,
|
||||
SignageProviderSourceDescriptor,
|
||||
} from "./contract.js";
|
||||
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Signage's own content provider.
|
||||
*
|
||||
* It implements the identical registration shape an external provider implements, registers through
|
||||
* the identical capability, and resolves through the identical code path (ADR 0001). That is the
|
||||
* whole point: there is **one** selection pipeline, so history, `previous`, restart recovery,
|
||||
* audience variants, and offline continuation are written once rather than twice and drifting.
|
||||
*
|
||||
* Two things are special about it, and only two:
|
||||
*
|
||||
* - Its renderer is `signage.native-image`, which is not a browser contribution at all. A display
|
||||
* that sees that identity paints an image element itself instead of asking the host to mount
|
||||
* anything, because the content is Signage's own.
|
||||
* - Its payload shape is Signage's own, so the display-state route may read it — to mint fresh
|
||||
* delivery URLs from the stored object keys. Every *other* provider's payload stays opaque.
|
||||
*/
|
||||
|
||||
/** The one source Signage's own provider offers: every ready, currently-available image, in the
|
||||
* order they were added, cycling forever. A pinned slot does not use this at all. */
|
||||
export const SIGNAGE_NATIVE_LIBRARY_SOURCE = "library";
|
||||
|
||||
/** What a native-image selection carries. The renditions are **object keys**, not URLs: a URL is a
|
||||
* bearer capability with an expiry, minted when something is about to be delivered and never
|
||||
* stored (ADR 0007). */
|
||||
// A `type` rather than an `interface` on purpose: TypeScript grants a type alias an implicit index
|
||||
// signature, so this shape is assignable to `SignageJsonValue` without a cast — which is the
|
||||
// compiler proving that everything Signage's own provider emits really is finite JSON.
|
||||
export type SignageNativeImagePayload = {
|
||||
readonly kind: "native-image";
|
||||
readonly imageId: string;
|
||||
readonly displayName: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly renditionKeys: { readonly display: string; readonly thumbnail: string };
|
||||
};
|
||||
|
||||
function isAvailable(image: StoredImage, at: Date): boolean {
|
||||
if (image.status !== "ready") {
|
||||
return false;
|
||||
}
|
||||
// Half-open: available up to but not including the expiration instant.
|
||||
return image.expiresAt === null || at < image.expiresAt;
|
||||
}
|
||||
|
||||
function payloadFor(image: StoredImage): SignageNativeImagePayload {
|
||||
const display = image.renditions.find((rendition) => rendition.kind === "display");
|
||||
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
|
||||
return {
|
||||
displayName: image.displayName,
|
||||
height: image.height,
|
||||
imageId: image.id,
|
||||
kind: "native-image",
|
||||
renditionKeys: {
|
||||
display: display?.objectKey ?? image.originalObjectKey,
|
||||
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
|
||||
},
|
||||
width: image.width,
|
||||
};
|
||||
}
|
||||
|
||||
function resolutionFor(image: StoredImage): SignageProviderResolution {
|
||||
return {
|
||||
availableUntil: image.expiresAt?.toISOString() ?? null,
|
||||
// The image's own content hash is its revision: different bytes are a different asset, so a
|
||||
// rendition can never change underneath a revision that stayed the same.
|
||||
itemId: image.id,
|
||||
itemRevision: image.contentSha256.slice(0, 32),
|
||||
// One variant, under the default key. Native images have no audience-specific rendering: a
|
||||
// church's slide is the same slide in the room and online.
|
||||
variants: [{ audienceKey: null, payload: payloadFor(image) }],
|
||||
};
|
||||
}
|
||||
|
||||
export interface NativeImageProviderOptions {
|
||||
readonly store: SignageStore;
|
||||
readonly defaultDurationMs: number;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the registration for Signage's built-in image provider.
|
||||
*
|
||||
* The library source's rotation cursor is held **in memory, per selection scope**. That is a
|
||||
* deliberate simplification with a stated cost: a restart resumes the library rotation from the
|
||||
* image that last played in that scope when Signage can tell it — which it can, because it persists
|
||||
* every resolved selection — and from the beginning when it cannot. The current slide itself is
|
||||
* unaffected either way, because that is persisted authoritative state rather than a cursor.
|
||||
*/
|
||||
export interface NativeImageProvider {
|
||||
readonly registration: SignageProviderRegistration;
|
||||
/** Re-seeds a scope's rotation from what actually played last, so a restart continues the library
|
||||
* where it left off rather than at the beginning. */
|
||||
seedCursor(selectionScopeId: string, itemId: string): void;
|
||||
}
|
||||
|
||||
export function createNativeImageProvider(
|
||||
options: NativeImageProviderOptions,
|
||||
): NativeImageProvider {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const cursors = new Map<string, string>();
|
||||
|
||||
async function available(at: Date): Promise<readonly StoredImage[]> {
|
||||
const images = await options.store.listImages();
|
||||
return (
|
||||
images
|
||||
.filter((image) => isAvailable(image, at))
|
||||
// Oldest first, so a rotation an operator watched yesterday runs in the same order today.
|
||||
.slice()
|
||||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
||||
);
|
||||
}
|
||||
|
||||
const registration: SignageProviderRegistration = {
|
||||
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
|
||||
defaultDurationMs: options.defaultDurationMs,
|
||||
|
||||
async listItems(
|
||||
_request: SignageProviderDiscoveryRequest,
|
||||
): Promise<readonly SignageProviderItemDescriptor[]> {
|
||||
const at = now();
|
||||
const images = await available(at);
|
||||
return images.map((image) => ({
|
||||
availableUntil: image.expiresAt?.toISOString() ?? null,
|
||||
description: `${image.width}×${image.height}`,
|
||||
id: image.id,
|
||||
label: image.displayName,
|
||||
}));
|
||||
},
|
||||
|
||||
listSources(
|
||||
_request: SignageProviderDiscoveryRequest,
|
||||
): readonly SignageProviderSourceDescriptor[] {
|
||||
return [
|
||||
{
|
||||
description:
|
||||
"Every Signage image that is ready and not expired, in the order it was added.",
|
||||
id: SIGNAGE_NATIVE_LIBRARY_SOURCE,
|
||||
label: "All Signage images",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||
providerVersion: "1.0.0",
|
||||
|
||||
async refresh(
|
||||
request: SignageProviderRefreshRequest,
|
||||
): Promise<SignageProviderResolution | null> {
|
||||
const image = await options.store.findImage(request.itemId);
|
||||
if (!image) {
|
||||
// The operator deleted it. That is a withdrawal, not a failure: the item is genuinely gone,
|
||||
// and the slot should stop showing it at the next safe boundary rather than keeping a stale
|
||||
// frame forever.
|
||||
return {
|
||||
itemId: request.itemId,
|
||||
itemRevision: "deleted",
|
||||
variants: [{ audienceKey: null, payload: { kind: "withdrawn" } }],
|
||||
withdrawn: true,
|
||||
};
|
||||
}
|
||||
if (!isAvailable(image, new Date(request.at))) {
|
||||
// Expired or not yet ready: nothing to show right now, which is a skip rather than an error.
|
||||
return null;
|
||||
}
|
||||
return resolutionFor(image);
|
||||
},
|
||||
|
||||
renderer: { contributionId: SIGNAGE_NATIVE_RENDERER, contributionVersion: "1.0.0" },
|
||||
|
||||
async resolveNext(
|
||||
request: SignageProviderResolveRequest,
|
||||
): Promise<SignageProviderResolution | null> {
|
||||
if (request.sourceId !== SIGNAGE_NATIVE_LIBRARY_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
const images = await available(new Date(request.at));
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const previous = cursors.get(request.selectionScopeId);
|
||||
const index =
|
||||
previous === undefined ? -1 : images.findIndex((image) => image.id === previous);
|
||||
// Round robin. An image added or removed since the last turn simply shifts the ring; nothing
|
||||
// about the cursor is an index into a list that may have changed underneath it.
|
||||
const next = images[(index + 1) % images.length] as StoredImage;
|
||||
cursors.set(request.selectionScopeId, next.id);
|
||||
return resolutionFor(next);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
registration,
|
||||
seedCursor(selectionScopeId: string, itemId: string): void {
|
||||
cursors.set(selectionScopeId, itemId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import type {
|
||||
SignageContentProviderCapability,
|
||||
SignageJsonValue,
|
||||
SignageProviderDiscoveryRequest,
|
||||
SignageProviderHandle,
|
||||
SignageProviderInvalidation,
|
||||
SignageProviderItemDescriptor,
|
||||
SignageProviderRefreshRequest,
|
||||
SignageProviderRegistration,
|
||||
SignageProviderResolveRequest,
|
||||
SignageProviderSourceDescriptor,
|
||||
} from "./contract.js";
|
||||
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
|
||||
import {
|
||||
type ParsedProviderResolution,
|
||||
parseItemDescriptors,
|
||||
parseProviderRegistration,
|
||||
parseProviderResolution,
|
||||
parseSourceDescriptors,
|
||||
SignageProviderError,
|
||||
type SignageProviderErrorCode,
|
||||
} from "./validation.js";
|
||||
|
||||
/**
|
||||
* The live registry of content providers.
|
||||
*
|
||||
* **In memory only, deliberately.** A registration is a running fact, not a stored one: a provider
|
||||
* that is uninstalled or disabled leaves no row behind claiming it exists, and Signage never
|
||||
* enumerates provider *modules* — only whatever registered itself while running.
|
||||
*
|
||||
* Every call out to a provider goes through {@link callProvider}: a deadline, a `catch`, and
|
||||
* validation of what came back. A provider cannot crash the Signage runtime, hang playback, corrupt
|
||||
* a selection, or put its own words on a screen or in a log.
|
||||
*/
|
||||
|
||||
/** What management sees about one provider. Everything here is either the provider's own declared
|
||||
* identity or a count Signage itself kept — never a provider message or a caught exception. */
|
||||
export interface SignageProviderHealth {
|
||||
readonly providerId: string;
|
||||
readonly providerVersion: string;
|
||||
readonly contractVersion: string;
|
||||
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
|
||||
readonly defaultDurationMs: number;
|
||||
readonly registeredAt: string;
|
||||
readonly consecutiveFailures: number;
|
||||
readonly lastFailureCode: SignageProviderErrorCode | null;
|
||||
readonly lastFailureAt: string | null;
|
||||
readonly lastSuccessAt: string | null;
|
||||
}
|
||||
|
||||
/** What Signage does when a provider says something it already resolved may have changed. The
|
||||
* registry only *reports* the notification; deciding what to refresh, and when, belongs to
|
||||
* playback, which is the only thing that knows what is on screen. */
|
||||
export type SignageInvalidationListener = (notice: {
|
||||
readonly providerId: string;
|
||||
readonly sourceId?: string;
|
||||
readonly itemId?: string;
|
||||
}) => void;
|
||||
|
||||
export interface SignageProviderRegistryOptions {
|
||||
/** How long one provider call may take. Signage's own operator setting. */
|
||||
readonly timeoutMs: number;
|
||||
readonly now?: () => Date;
|
||||
readonly onInvalidate?: SignageInvalidationListener;
|
||||
}
|
||||
|
||||
interface RegisteredProvider {
|
||||
readonly registration: SignageProviderRegistration;
|
||||
readonly registeredAt: Date;
|
||||
consecutiveFailures: number;
|
||||
lastFailureCode: SignageProviderErrorCode | null;
|
||||
lastFailureAt: Date | null;
|
||||
lastSuccessAt: Date | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SignageProviderRegistry {
|
||||
/** The capability implementation handed to `capabilities.provide`. */
|
||||
readonly capability: SignageContentProviderCapability;
|
||||
/** Every currently registered provider, in a stable order. */
|
||||
health(): readonly SignageProviderHealth[];
|
||||
has(providerId: string): boolean;
|
||||
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined;
|
||||
defaultDurationMs(providerId: string): number | undefined;
|
||||
listSources(
|
||||
providerId: string,
|
||||
request: SignageProviderDiscoveryRequest,
|
||||
): Promise<readonly SignageProviderSourceDescriptor[]>;
|
||||
listItems(
|
||||
providerId: string,
|
||||
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||
): Promise<readonly SignageProviderItemDescriptor[]>;
|
||||
resolveNext(
|
||||
providerId: string,
|
||||
request: SignageProviderResolveRequest,
|
||||
): Promise<ProviderCallOutcome>;
|
||||
refresh(providerId: string, request: SignageProviderRefreshRequest): Promise<ProviderCallOutcome>;
|
||||
/** Releases every registration. Used when Signage itself stops. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three answers a resolution call can produce, kept distinct all the way up (ADR 0002).
|
||||
*
|
||||
* Collapsing them would be this design's biggest mistake: an empty pool would show an error, and a
|
||||
* provider restart would clear the screen.
|
||||
*/
|
||||
export type ProviderCallOutcome =
|
||||
/** The provider answered. `resolution` is `null` when nothing is available right now. */
|
||||
| { readonly kind: "resolved"; readonly resolution: ParsedProviderResolution | null }
|
||||
/** The provider is not registered at all — uninstalled, disabled, or still starting. */
|
||||
| { readonly kind: "unregistered" }
|
||||
/** The provider threw, rejected, timed out, or returned something unusable. Temporary. */
|
||||
| { readonly kind: "failed"; readonly code: SignageProviderErrorCode };
|
||||
|
||||
export function createProviderRegistry(
|
||||
options: SignageProviderRegistryOptions,
|
||||
): SignageProviderRegistry {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const providers = new Map<string, RegisteredProvider>();
|
||||
|
||||
function recordSuccess(entry: RegisteredProvider): void {
|
||||
entry.consecutiveFailures = 0;
|
||||
entry.lastFailureCode = null;
|
||||
entry.lastSuccessAt = now();
|
||||
}
|
||||
|
||||
function recordFailure(entry: RegisteredProvider, code: SignageProviderErrorCode): void {
|
||||
entry.consecutiveFailures += 1;
|
||||
entry.lastFailureCode = code;
|
||||
entry.lastFailureAt = now();
|
||||
}
|
||||
|
||||
/**
|
||||
* One bounded, sanitized call into a provider.
|
||||
*
|
||||
* The deadline is what stops a provider that hangs from stopping a channel group: playback is
|
||||
* serialized per group, so a call that never settles would freeze the wall. The timer never
|
||||
* rejects the provider's own promise — a late answer is simply ignored, because cancelling
|
||||
* another module's work is not something this contract can express.
|
||||
*/
|
||||
async function callProvider<Result>(
|
||||
entry: RegisteredProvider,
|
||||
invoke: () => Promise<Result> | Result,
|
||||
): Promise<{ ok: true; value: Result } | { ok: false; code: SignageProviderErrorCode }> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const value = await Promise.race([
|
||||
Promise.resolve().then(invoke),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new SignageProviderError("SIGNAGE_PROVIDER_UNAVAILABLE")),
|
||||
options.timeoutMs,
|
||||
);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
recordSuccess(entry);
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
// Whatever the provider threw is discarded here. Its message, stack, and cause never reach a
|
||||
// log, a response, or a screen.
|
||||
const code =
|
||||
error instanceof SignageProviderError && error.code === "SIGNAGE_PROVIDER_RESULT_INVALID"
|
||||
? "SIGNAGE_PROVIDER_RESULT_INVALID"
|
||||
: "SIGNAGE_PROVIDER_UNAVAILABLE";
|
||||
recordFailure(entry, code);
|
||||
return { code, ok: false };
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function active(providerId: string): RegisteredProvider | undefined {
|
||||
const entry = providers.get(providerId);
|
||||
return entry?.active === true ? entry : undefined;
|
||||
}
|
||||
|
||||
const capability: SignageContentProviderCapability = Object.freeze({
|
||||
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
|
||||
|
||||
register(value: unknown): SignageProviderHandle {
|
||||
const registration = parseProviderRegistration(value);
|
||||
const existing = providers.get(registration.providerId);
|
||||
if (existing?.active === true) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_DUPLICATE", registration.providerId);
|
||||
}
|
||||
|
||||
const entry: RegisteredProvider = {
|
||||
active: true,
|
||||
consecutiveFailures: 0,
|
||||
lastFailureAt: null,
|
||||
lastFailureCode: null,
|
||||
lastSuccessAt: null,
|
||||
registeredAt: now(),
|
||||
registration,
|
||||
};
|
||||
providers.set(registration.providerId, entry);
|
||||
|
||||
return Object.freeze({
|
||||
invalidate(change?: SignageProviderInvalidation): void {
|
||||
// A late notification from a module being torn down is ordinary, not an error.
|
||||
if (!entry.active) {
|
||||
return;
|
||||
}
|
||||
options.onInvalidate?.({
|
||||
providerId: registration.providerId,
|
||||
...(typeof change?.sourceId === "string" ? { sourceId: change.sourceId } : {}),
|
||||
...(typeof change?.itemId === "string" ? { itemId: change.itemId } : {}),
|
||||
});
|
||||
},
|
||||
unregister(): void {
|
||||
// Idempotent, which is what makes a rollback after a partially completed `start` safe:
|
||||
// the hook that failed runs its own cleanup, and reverse-order shutdown runs it again.
|
||||
if (!entry.active) {
|
||||
return;
|
||||
}
|
||||
entry.active = false;
|
||||
providers.delete(registration.providerId);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
capability,
|
||||
|
||||
clear(): void {
|
||||
for (const entry of providers.values()) {
|
||||
entry.active = false;
|
||||
}
|
||||
providers.clear();
|
||||
},
|
||||
|
||||
defaultDurationMs(providerId: string): number | undefined {
|
||||
return active(providerId)?.registration.defaultDurationMs;
|
||||
},
|
||||
|
||||
has(providerId: string): boolean {
|
||||
return active(providerId) !== undefined;
|
||||
},
|
||||
|
||||
health(): readonly SignageProviderHealth[] {
|
||||
return [...providers.values()]
|
||||
.filter((entry) => entry.active)
|
||||
.map((entry) => ({
|
||||
consecutiveFailures: entry.consecutiveFailures,
|
||||
contractVersion: entry.registration.contractVersion,
|
||||
defaultDurationMs: entry.registration.defaultDurationMs,
|
||||
lastFailureAt: entry.lastFailureAt?.toISOString() ?? null,
|
||||
lastFailureCode: entry.lastFailureCode,
|
||||
lastSuccessAt: entry.lastSuccessAt?.toISOString() ?? null,
|
||||
providerId: entry.registration.providerId,
|
||||
providerVersion: entry.registration.providerVersion,
|
||||
registeredAt: entry.registeredAt.toISOString(),
|
||||
renderer: {
|
||||
contributionId: entry.registration.renderer.contributionId,
|
||||
contributionVersion: entry.registration.renderer.contributionVersion,
|
||||
},
|
||||
}))
|
||||
.sort((left, right) => left.providerId.localeCompare(right.providerId));
|
||||
},
|
||||
|
||||
async listItems(
|
||||
providerId: string,
|
||||
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||
): Promise<readonly SignageProviderItemDescriptor[]> {
|
||||
const entry = active(providerId);
|
||||
if (!entry?.registration.listItems) {
|
||||
return [];
|
||||
}
|
||||
const listItems = entry.registration.listItems.bind(entry.registration);
|
||||
const result = await callProvider(entry, () => listItems(request));
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return parseItemDescriptors(result.value);
|
||||
} catch {
|
||||
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async listSources(
|
||||
providerId: string,
|
||||
request: SignageProviderDiscoveryRequest,
|
||||
): Promise<readonly SignageProviderSourceDescriptor[]> {
|
||||
const entry = active(providerId);
|
||||
if (!entry) {
|
||||
return [];
|
||||
}
|
||||
const listSources = entry.registration.listSources.bind(entry.registration);
|
||||
const result = await callProvider(entry, () => listSources(request));
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return parseSourceDescriptors(result.value);
|
||||
} catch {
|
||||
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async refresh(
|
||||
providerId: string,
|
||||
request: SignageProviderRefreshRequest,
|
||||
): Promise<ProviderCallOutcome> {
|
||||
const entry = active(providerId);
|
||||
if (!entry) {
|
||||
return { kind: "unregistered" };
|
||||
}
|
||||
const refresh = entry.registration.refresh.bind(entry.registration);
|
||||
const result = await callProvider(entry, () => refresh(request));
|
||||
if (!result.ok) {
|
||||
return { code: result.code, kind: "failed" };
|
||||
}
|
||||
try {
|
||||
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
|
||||
} catch {
|
||||
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
|
||||
}
|
||||
},
|
||||
|
||||
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined {
|
||||
const entry = active(providerId);
|
||||
return entry
|
||||
? {
|
||||
contributionId: entry.registration.renderer.contributionId,
|
||||
contributionVersion: entry.registration.renderer.contributionVersion,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
|
||||
async resolveNext(
|
||||
providerId: string,
|
||||
request: SignageProviderResolveRequest,
|
||||
): Promise<ProviderCallOutcome> {
|
||||
const entry = active(providerId);
|
||||
if (!entry) {
|
||||
return { kind: "unregistered" };
|
||||
}
|
||||
const resolveNext = entry.registration.resolveNext.bind(entry.registration);
|
||||
const result = await callProvider(entry, () => resolveNext(request));
|
||||
if (!result.ok) {
|
||||
return { code: result.code, kind: "failed" };
|
||||
}
|
||||
try {
|
||||
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
|
||||
} catch {
|
||||
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { SignageJsonValue };
|
||||
@@ -0,0 +1,366 @@
|
||||
import type {
|
||||
SignageJsonValue,
|
||||
SignageProviderItemDescriptor,
|
||||
SignageProviderRegistration,
|
||||
SignageProviderSourceDescriptor,
|
||||
SignageProviderVariant,
|
||||
} from "./contract.js";
|
||||
import { isSupportedProviderContractVersion, SIGNAGE_DEFAULT_AUDIENCE_KEY } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Everything crossing the provider boundary is validated here, before it is believed.
|
||||
*
|
||||
* A provider is trusted code in the same process, so this is not a sandbox and never claims to be.
|
||||
* It is the difference between one module's bug staying one module's bug and a malformed value
|
||||
* reaching a television — a `NaN` duration that makes a slide last forever, a cyclic payload that
|
||||
* cannot be stored, an unbounded string that fills a realtime frame.
|
||||
*
|
||||
* Every rejection is a stable code carrying **nothing of the rejected value**.
|
||||
*/
|
||||
|
||||
export const SIGNAGE_PROVIDER_ERROR_CODES = [
|
||||
/** The registration is not the shape this contract accepts. */
|
||||
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||
/** The provider was written against a contract version Signage does not implement. */
|
||||
"SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
|
||||
/** A provider with this id is already registered. */
|
||||
"SIGNAGE_PROVIDER_DUPLICATE",
|
||||
/** The provider returned something this contract does not accept. */
|
||||
"SIGNAGE_PROVIDER_RESULT_INVALID",
|
||||
/** The provider threw, rejected, or exceeded its deadline. Temporary by definition. */
|
||||
"SIGNAGE_PROVIDER_UNAVAILABLE",
|
||||
] as const;
|
||||
|
||||
export type SignageProviderErrorCode = (typeof SIGNAGE_PROVIDER_ERROR_CODES)[number];
|
||||
|
||||
const providerErrorMessages: Readonly<Record<SignageProviderErrorCode, string>> = Object.freeze({
|
||||
SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED:
|
||||
"This content provider was written against an unsupported contract version.",
|
||||
SIGNAGE_PROVIDER_DUPLICATE: "A content provider with this identifier is already registered.",
|
||||
SIGNAGE_PROVIDER_REGISTRATION_INVALID: "The content provider registration is not valid.",
|
||||
SIGNAGE_PROVIDER_RESULT_INVALID: "The content provider returned an unusable result.",
|
||||
SIGNAGE_PROVIDER_UNAVAILABLE: "The content provider could not be reached.",
|
||||
});
|
||||
|
||||
/** Carries a code and, at most, a value the **provider itself supplied as its own identity** — never
|
||||
* its payload, its exception, or anything of the host's. */
|
||||
export class SignageProviderError extends Error {
|
||||
readonly code: SignageProviderErrorCode;
|
||||
readonly providerId?: string;
|
||||
|
||||
constructor(code: SignageProviderErrorCode, providerId?: string) {
|
||||
super(providerErrorMessages[code]);
|
||||
this.name = "SignageProviderError";
|
||||
this.code = code;
|
||||
if (providerId !== undefined) {
|
||||
this.providerId = providerId;
|
||||
}
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Signage's own bounds on what a provider may hand back. They exist so one provider cannot make a
|
||||
* realtime frame, a database row, or a slide duration unusable for everything else. */
|
||||
export const SIGNAGE_PROVIDER_LIMITS = Object.freeze({
|
||||
/** A slide shorter than a second is a flicker; one longer than an hour is a static hold. */
|
||||
maxDurationMs: 3_600_000,
|
||||
/** One selection's payloads, serialized. Well inside the platform's 64 KiB realtime ceiling even
|
||||
* before Signage decides to deliver large state over HTTP instead. */
|
||||
maxPayloadBytes: 32 * 1024,
|
||||
/** Discovery is a picker, not a catalogue. */
|
||||
maxDiscoveryResults: 500,
|
||||
maxIdentifierLength: 200,
|
||||
maxLabelLength: 200,
|
||||
maxVariants: 16,
|
||||
minDurationMs: 1000,
|
||||
});
|
||||
|
||||
const IDENTIFIER = /^[\w][\w.:-]*$/u;
|
||||
const SEMANTIC_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/u;
|
||||
|
||||
function isBoundedString(value: unknown, maxLength: number): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function isIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
isBoundedString(value, SIGNAGE_PROVIDER_LIMITS.maxIdentifierLength) && IDENTIFIER.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isFunction(value: unknown): boolean {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
|
||||
* `JSON.stringify` and hoping.
|
||||
*
|
||||
* `undefined`, a function, a `Symbol`, a `Date`, a class instance, a cycle, `NaN`, and `Infinity`
|
||||
* all either vanish or throw on the way to storage or a browser, and the failure would surface far
|
||||
* from the provider that caused it. The walk is bounded, so a cycle is caught rather than followed.
|
||||
*/
|
||||
export function isFiniteJson(value: unknown, seen = new Set<object>()): value is SignageJsonValue {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
const isArray = Array.isArray(value);
|
||||
if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) {
|
||||
return false;
|
||||
}
|
||||
const entries = isArray ? value : Object.values(value);
|
||||
const valid = entries.every((entry) => isFiniteJson(entry, seen));
|
||||
seen.delete(value);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function jsonByteLength(value: SignageJsonValue): number {
|
||||
return new TextEncoder().encode(JSON.stringify(value) ?? "").length;
|
||||
}
|
||||
|
||||
function isInstantOrNull(value: unknown): value is string | null | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
// Absolute instants only. An ambiguous local timestamp is refused rather than guessed at.
|
||||
return !Number.isNaN(Date.parse(value)) && /[Zz]|[+-]\d{2}:?\d{2}$/u.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a registration and returns it in its canonical form.
|
||||
*
|
||||
* Throws {@link SignageProviderError} — never a `TypeError`, never anything carrying the offending
|
||||
* value — because the caller is another module's `start` hook, and what it does with a rejection is
|
||||
* its own business.
|
||||
*/
|
||||
export function parseProviderRegistration(value: unknown): SignageProviderRegistration {
|
||||
if (!isRecord(value)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
|
||||
}
|
||||
const providerId = value.providerId;
|
||||
if (!isIdentifier(providerId)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
|
||||
}
|
||||
if (typeof value.contractVersion !== "string") {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||
}
|
||||
if (!isSupportedProviderContractVersion(value.contractVersion)) {
|
||||
// Reported separately from a malformed registration, because an operator can act on it: the
|
||||
// provider module needs an update, and nothing about it is broken.
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", providerId);
|
||||
}
|
||||
if (typeof value.providerVersion !== "string" || !SEMANTIC_VERSION.test(value.providerVersion)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||
}
|
||||
const renderer = value.renderer;
|
||||
if (
|
||||
!isRecord(renderer) ||
|
||||
!isIdentifier(renderer.contributionId) ||
|
||||
typeof renderer.contributionVersion !== "string" ||
|
||||
!SEMANTIC_VERSION.test(renderer.contributionVersion)
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||
}
|
||||
const defaultDurationMs = value.defaultDurationMs;
|
||||
if (
|
||||
typeof defaultDurationMs !== "number" ||
|
||||
!Number.isSafeInteger(defaultDurationMs) ||
|
||||
defaultDurationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
|
||||
defaultDurationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||
}
|
||||
if (
|
||||
!isFunction(value.listSources) ||
|
||||
!isFunction(value.resolveNext) ||
|
||||
!isFunction(value.refresh) ||
|
||||
(value.listItems !== undefined && !isFunction(value.listItems))
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||
}
|
||||
|
||||
return value as unknown as SignageProviderRegistration;
|
||||
}
|
||||
|
||||
export function parseSourceDescriptors(value: unknown): readonly SignageProviderSourceDescriptor[] {
|
||||
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
return value.map((entry) => {
|
||||
if (
|
||||
!isRecord(entry) ||
|
||||
!isIdentifier(entry.id) ||
|
||||
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
|
||||
(entry.description !== undefined &&
|
||||
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
...(entry.description === undefined ? {} : { description: entry.description as string }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseItemDescriptors(value: unknown): readonly SignageProviderItemDescriptor[] {
|
||||
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
return value.map((entry) => {
|
||||
if (
|
||||
!isRecord(entry) ||
|
||||
!isIdentifier(entry.id) ||
|
||||
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
|
||||
!isInstantOrNull(entry.availableUntil) ||
|
||||
(entry.description !== undefined &&
|
||||
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
...(entry.description === undefined ? {} : { description: entry.description as string }),
|
||||
...(entry.availableUntil === undefined
|
||||
? {}
|
||||
: { availableUntil: entry.availableUntil as string | null }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The canonical form of a resolution: variants keyed and deduplicated, the default guaranteed
|
||||
* present, and every bound already applied. */
|
||||
export interface ParsedProviderResolution {
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
readonly durationMs: number | undefined;
|
||||
readonly availableUntil: Date | null;
|
||||
readonly withdrawn: boolean;
|
||||
readonly presentation: SignageJsonValue | undefined;
|
||||
/** Always contains {@link SIGNAGE_DEFAULT_AUDIENCE_KEY}. */
|
||||
readonly variants: ReadonlyMap<string, SignageJsonValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one resolution.
|
||||
*
|
||||
* `null` passes straight through: "nothing available right now" is an answer, not a failure, and
|
||||
* conflating the two is how an empty Trivia pool becomes an error on a lobby wall.
|
||||
*/
|
||||
export function parseProviderResolution(value: unknown): ParsedProviderResolution | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (!isIdentifier(value.itemId) || !isBoundedString(value.itemRevision, 128)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (
|
||||
value.durationMs !== undefined &&
|
||||
(typeof value.durationMs !== "number" ||
|
||||
!Number.isSafeInteger(value.durationMs) ||
|
||||
value.durationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
|
||||
value.durationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs)
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (!isInstantOrNull(value.availableUntil)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (value.withdrawn !== undefined && typeof value.withdrawn !== "boolean") {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (value.presentation !== undefined && !isFiniteJson(value.presentation)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
|
||||
const rawVariants = value.variants;
|
||||
if (
|
||||
!Array.isArray(rawVariants) ||
|
||||
rawVariants.length === 0 ||
|
||||
rawVariants.length > SIGNAGE_PROVIDER_LIMITS.maxVariants
|
||||
) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
|
||||
const variants = new Map<string, SignageJsonValue>();
|
||||
let payloadBytes = 0;
|
||||
for (const entry of rawVariants as readonly unknown[]) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
const key =
|
||||
entry.audienceKey === null || entry.audienceKey === undefined
|
||||
? SIGNAGE_DEFAULT_AUDIENCE_KEY
|
||||
: entry.audienceKey;
|
||||
if (!isBoundedString(key, 64)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (variants.has(key)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
if (!isFiniteJson(entry.payload)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
payloadBytes += jsonByteLength(entry.payload);
|
||||
if (payloadBytes > SIGNAGE_PROVIDER_LIMITS.maxPayloadBytes) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
variants.set(key, entry.payload);
|
||||
}
|
||||
|
||||
// Every channel must have something to render, and a provider with no audience-specific content
|
||||
// returns exactly one variant. Requiring the default is what makes the fallback rule real.
|
||||
if (!variants.has(SIGNAGE_DEFAULT_AUDIENCE_KEY)) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
|
||||
const withdrawn = value.withdrawn === true;
|
||||
const availableUntil =
|
||||
typeof value.availableUntil === "string" ? new Date(value.availableUntil) : null;
|
||||
return {
|
||||
availableUntil,
|
||||
durationMs: typeof value.durationMs === "number" ? value.durationMs : undefined,
|
||||
itemId: value.itemId,
|
||||
itemRevision: value.itemRevision,
|
||||
presentation:
|
||||
value.presentation === undefined ? undefined : (value.presentation as SignageJsonValue),
|
||||
variants,
|
||||
withdrawn,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProviderVariants(
|
||||
variants: readonly SignageProviderVariant[],
|
||||
): ReadonlyMap<string, SignageJsonValue> {
|
||||
const parsed = parseProviderResolution({
|
||||
itemId: "x",
|
||||
itemRevision: "1",
|
||||
variants,
|
||||
});
|
||||
if (!parsed) {
|
||||
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||
}
|
||||
return parsed.variants;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js";
|
||||
import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js";
|
||||
import {
|
||||
createImageDeliveryUrls,
|
||||
deleteNativeImage,
|
||||
ingestNativeImage,
|
||||
updateNativeImage,
|
||||
} from "../images/service.js";
|
||||
import { readBoundedBody } from "../images/streaming.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import type { StoredImage } from "../store/types.js";
|
||||
|
||||
const FILENAME_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* A filename is a label an operator sees, never a path and never an object key.
|
||||
*
|
||||
* Path separators and control characters are refused rather than escaped, because a display name has
|
||||
* no business containing either — and the value is echoed back to a browser, where a control
|
||||
* character is a rendering hazard and a separator invites something to read it as a location.
|
||||
*/
|
||||
function isSafeLabel(value: string): boolean {
|
||||
if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
if (character === "/" || character === "\\") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function queryValue(request: Request, name: string): string | undefined {
|
||||
const value = new URL(request.url).searchParams.get(name);
|
||||
return value === null ? undefined : value;
|
||||
}
|
||||
|
||||
function requireLabel(request: Request, field: string, fallback?: string): string {
|
||||
const value = queryValue(request, field)?.trim();
|
||||
if (value === undefined || value.length === 0) {
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
if (!isSafeLabel(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readExpiresAt(request: Request): Date | null {
|
||||
const raw = queryValue(request, "expiresAt");
|
||||
if (raw === undefined || raw === "") {
|
||||
// Null is permanent. Local age never invents an expiration for a church's own slide.
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key
|
||||
* never leaves the server — a browser has no use for it, and it is the one durable thing Signage
|
||||
* stores about where bytes live. */
|
||||
async function describe(
|
||||
context: ModuleRouteHandlerContext,
|
||||
image: StoredImage,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const delivery = await createImageDeliveryUrls(context.storage, image.renditions);
|
||||
return {
|
||||
byteSize: image.originalByteSize,
|
||||
contentSha256: image.contentSha256,
|
||||
contentType: image.originalContentType,
|
||||
createdAt: image.createdAt.toISOString(),
|
||||
displayName: image.displayName,
|
||||
expiresAt: image.expiresAt?.toISOString() ?? null,
|
||||
hasAlpha: image.hasAlpha,
|
||||
height: image.height,
|
||||
id: image.id,
|
||||
originalFilename: image.originalFilename,
|
||||
renditions: delivery,
|
||||
status: image.status,
|
||||
width: image.width,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/images",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const images = await createSignageStore(context.services.database).listImages();
|
||||
return Response.json({
|
||||
images: await Promise.all(images.map((image) => describe(context, image))),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/images",
|
||||
permission: "signage.assets.manage",
|
||||
// One image, streamed. The platform counts bytes as the handler consumes them and never
|
||||
// materializes the body; Signage counts them too and refuses at its own smaller limit first.
|
||||
// The filename and display name travel in the query string, so the body is exactly the image.
|
||||
maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES,
|
||||
bodyMode: "streamed",
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const filename = requireLabel(request, "filename");
|
||||
const displayName = requireLabel(request, "displayName", filename);
|
||||
const expiresAt = readExpiresAt(request);
|
||||
|
||||
// The whole body is read, and only then validated and committed. A `413` can arrive after a
|
||||
// streamed handler has started, which is exactly why nothing is written until this returns.
|
||||
const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES);
|
||||
|
||||
const result = await ingestNativeImage({
|
||||
bytes,
|
||||
displayName,
|
||||
expiresAt,
|
||||
filename,
|
||||
storage: context.storage,
|
||||
store: createSignageStore(context.services.database),
|
||||
});
|
||||
return Response.json(
|
||||
{ deduplicated: result.deduplicated, image: await describe(context, result.image) },
|
||||
{ status: result.deduplicated ? 200 : 201 },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/images/:imageId",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const imageId = requireUuidParam(context, "imageId");
|
||||
const image = await createSignageStore(context.services.database).findImage(imageId);
|
||||
if (!image) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
return Response.json({ image: await describe(context, image) });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/images/:imageId",
|
||||
permission: "signage.assets.manage",
|
||||
// A rename and an expiration date. Nothing about this route needs a large body.
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const imageId = requireUuidParam(context, "imageId");
|
||||
const body = await readJsonObject(request);
|
||||
const displayName = optionalString(body, "displayName", {
|
||||
maxLength: FILENAME_MAX_LENGTH,
|
||||
});
|
||||
if (displayName !== undefined && !isSafeLabel(displayName)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" });
|
||||
}
|
||||
const image = await updateNativeImage({
|
||||
assetId: imageId,
|
||||
displayName,
|
||||
expiresAt: optionalInstant(body, "expiresAt"),
|
||||
store: createSignageStore(context.services.database),
|
||||
});
|
||||
return Response.json({ image: await describe(context, image) });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/images/:imageId",
|
||||
permission: "signage.assets.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const imageId = requireUuidParam(context, "imageId");
|
||||
await deleteNativeImage({
|
||||
assetId: imageId,
|
||||
storage: context.storage,
|
||||
store: createSignageStore(context.services.database),
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
isUuid,
|
||||
optionalInstant,
|
||||
readJsonObject,
|
||||
requiredString,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import type { SignagePlaybackService } from "../playback/service.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
|
||||
|
||||
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
|
||||
* of the platform uses for a local identifier. */
|
||||
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
|
||||
return {
|
||||
channels: detail.channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
isDefault: channel.isDefault,
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
})),
|
||||
id: detail.group.id,
|
||||
name: detail.group.name,
|
||||
revision: detail.group.revision,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerPlaybackRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
playback: () => SignagePlaybackService,
|
||||
): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/channel-groups",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groups = await createSignageStore(context.services.database).listChannelGroups();
|
||||
return Response.json({
|
||||
groups: await Promise.all(
|
||||
groups.map(async (group) => ({
|
||||
...describeGroup(group),
|
||||
playback: await playback().snapshot(group.group.id),
|
||||
})),
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/channel-groups",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const store = createSignageStore(context.services.database);
|
||||
const groupId = randomUUID();
|
||||
// A group is created with its default channel and its playback row together: a group
|
||||
// without a default channel has nowhere for a viewer to land.
|
||||
await store.createChannelGroup({
|
||||
channelId: randomUUID(),
|
||||
channelName: "Default",
|
||||
groupId,
|
||||
name: requiredString(body, "name", { maxLength: 120 }),
|
||||
});
|
||||
const created = await store.findChannelGroup(groupId);
|
||||
if (!created) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return Response.json(describeGroup(created), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/channel-groups/:groupId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const store = createSignageStore(context.services.database);
|
||||
const groups = await store.listChannelGroups();
|
||||
if (groups.length <= 1) {
|
||||
// An installation always has somewhere for a viewer to land.
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
||||
}
|
||||
await store.deleteChannelGroup(groupId);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/channel-groups/:groupId/channels",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const body = await readJsonObject(request);
|
||||
const key = requiredString(body, "key", { maxLength: 64 });
|
||||
if (!AUDIENCE_KEY.test(key) || key === "default") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
|
||||
}
|
||||
const store = createSignageStore(context.services.database);
|
||||
const group = await store.findChannelGroup(groupId);
|
||||
if (!group) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
if (group.channels.some((channel) => channel.key === key)) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
|
||||
}
|
||||
await store.addChannel({
|
||||
groupId,
|
||||
id: randomUUID(),
|
||||
isDefault: false,
|
||||
key,
|
||||
name: requiredString(body, "name", { maxLength: 120 }),
|
||||
});
|
||||
const updated = await store.findChannelGroup(groupId);
|
||||
if (!updated) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return Response.json(describeGroup(updated), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/channels/:channelId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const channelId = requireUuidParam(context, "channelId");
|
||||
try {
|
||||
await createSignageStore(context.services.database).removeChannel(channelId);
|
||||
} catch (error) {
|
||||
if (error instanceof SignageStoreConflict) {
|
||||
// A group's default channel is the one a viewer lands on. It cannot be removed.
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/playback/:groupId",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const store = createSignageStore(context.services.database);
|
||||
const snapshot = await playback().snapshot(groupId);
|
||||
const selection =
|
||||
snapshot.currentSelectionId === null
|
||||
? undefined
|
||||
: await store.findSelection(snapshot.currentSelectionId);
|
||||
const history = await store.listSelections(groupId, 20);
|
||||
return Response.json({
|
||||
current:
|
||||
selection === undefined
|
||||
? null
|
||||
: {
|
||||
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
|
||||
durationMs: selection.durationMs,
|
||||
id: selection.id,
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendererContributionId: selection.rendererContributionId,
|
||||
revision: selection.revision,
|
||||
stale: selection.stale,
|
||||
startedAtMs: selection.startedAt?.getTime() ?? null,
|
||||
withdrawn: selection.withdrawn,
|
||||
},
|
||||
// Identity and timing only. A payload belongs to a display, which asks for the channel
|
||||
// state it is authorized for.
|
||||
history: history.map((entry) => ({
|
||||
id: entry.id,
|
||||
itemId: entry.itemId,
|
||||
providerId: entry.providerId,
|
||||
sequence: entry.sequence,
|
||||
startedAtMs: entry.startedAt?.getTime() ?? null,
|
||||
})),
|
||||
snapshot,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function command(
|
||||
path: string,
|
||||
run: (
|
||||
service: SignagePlaybackService,
|
||||
groupId: string,
|
||||
body: Record<string, unknown>,
|
||||
context: ModuleRouteHandlerContext,
|
||||
) => Promise<unknown>,
|
||||
options: { readonly maxBodyBytes: number },
|
||||
): void {
|
||||
http.route({
|
||||
method: "POST",
|
||||
path,
|
||||
permission: "signage.playback.control",
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
|
||||
return Response.json(await run(playback(), groupId, body, context));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
command(
|
||||
"/playback/:groupId/activate",
|
||||
async (service, groupId, body) => {
|
||||
const timelineId = body.timelineId;
|
||||
if (!isUuid(timelineId)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
|
||||
}
|
||||
const at = optionalInstant(body, "at");
|
||||
if (at) {
|
||||
return service.activate(groupId, timelineId, { at });
|
||||
}
|
||||
const when = body.when === "next-boundary" ? "next-boundary" : "now";
|
||||
return service.activate(groupId, timelineId, when);
|
||||
},
|
||||
{ maxBodyBytes: 2048 },
|
||||
);
|
||||
|
||||
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command(
|
||||
"/playback/:groupId/blackout",
|
||||
(service, groupId, body) => {
|
||||
if (typeof body.on !== "boolean") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
|
||||
}
|
||||
return service.setBlackout(groupId, body.on);
|
||||
},
|
||||
{ maxBodyBytes: 1024 },
|
||||
);
|
||||
command(
|
||||
"/playback/:groupId/static",
|
||||
(service, groupId, body) => {
|
||||
const assetId = body.assetId;
|
||||
if (assetId === null) {
|
||||
return service.setStaticHold(groupId, null);
|
||||
}
|
||||
if (!isUuid(assetId)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
|
||||
}
|
||||
return service.setStaticHold(groupId, assetId);
|
||||
},
|
||||
{ maxBodyBytes: 1024 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import { readSignageSettings } from "../settings.js";
|
||||
|
||||
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
|
||||
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
|
||||
* bound before any lookup. */
|
||||
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function requireProviderId(context: ModuleRouteHandlerContext): string {
|
||||
const value = context.params.providerId;
|
||||
if (value === undefined || !PROVIDER_ID.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider health and discovery.
|
||||
*
|
||||
* Everything these routes return is either a provider's own declared identity or a count Signage
|
||||
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
|
||||
* makes "an incompatible provider is clearly reported in management" true without also making a
|
||||
* provider's internals an operator-visible surface.
|
||||
*/
|
||||
export function registerProviderRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
registry: () => SignageProviderRegistry,
|
||||
): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler() {
|
||||
return signageBoundary(() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
// Only what is actually registered right now. A registration is a running fact, not a
|
||||
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
|
||||
// exists.
|
||||
providers: registry().health(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers/:providerId/sources",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const providerId = requireProviderId(context);
|
||||
const settings = readSignageSettings(context.config);
|
||||
// A provider that is not registered, is failing, or answers with something unusable yields
|
||||
// an empty list rather than an error: the editor should say "no sources right now", not
|
||||
// break, and provider health already reports why.
|
||||
const sources = await registry().listSources(providerId, {
|
||||
at: new Date().toISOString(),
|
||||
timezone: settings.timezone,
|
||||
});
|
||||
return Response.json({ providerId, sources });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers/:providerId/items",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const providerId = requireProviderId(context);
|
||||
const settings = readSignageSettings(context.config);
|
||||
const sourceId = new URL(request.url).searchParams.get("sourceId");
|
||||
const items = await registry().listItems(providerId, {
|
||||
at: new Date().toISOString(),
|
||||
timezone: settings.timezone,
|
||||
...(sourceId === null ? {} : { sourceId }),
|
||||
});
|
||||
return Response.json({ items, providerId });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
isUuid,
|
||||
optionalBoolean,
|
||||
optionalInstant,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonObject,
|
||||
requiredString,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
|
||||
import {
|
||||
addProviderSource,
|
||||
addSlot,
|
||||
createTimeline,
|
||||
deleteTimeline,
|
||||
duplicateTimeline,
|
||||
removeProviderSource,
|
||||
removeSlot,
|
||||
reorderSlots,
|
||||
requireTimeline,
|
||||
SIGNAGE_TIMELINE_LIMITS,
|
||||
updateSlot,
|
||||
updateTimeline,
|
||||
} from "../timelines/service.js";
|
||||
|
||||
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||
return createSignageStore(context.services.database);
|
||||
}
|
||||
|
||||
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
|
||||
* receive — never an invented epoch or a locale-formatted representation. */
|
||||
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
|
||||
return {
|
||||
slots: detail.slots.map((slot) => ({
|
||||
durationOverrideMs: slot.durationOverrideMs,
|
||||
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
|
||||
enabled: slot.enabled,
|
||||
id: slot.id,
|
||||
itemId: slot.itemId,
|
||||
kind: slot.kind,
|
||||
position: slot.position,
|
||||
presentation: slot.presentation,
|
||||
providerId: slot.providerId,
|
||||
sourceId: slot.sourceId,
|
||||
})),
|
||||
sources: detail.sources.map((source) => ({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
sourceId: source.sourceId,
|
||||
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
|
||||
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
|
||||
})),
|
||||
timeline: {
|
||||
description: detail.timeline.description,
|
||||
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
|
||||
id: detail.timeline.id,
|
||||
name: detail.timeline.name,
|
||||
revision: detail.timeline.revision,
|
||||
updatedAt: detail.timeline.updatedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readPresentation(
|
||||
body: Record<string, unknown>,
|
||||
): Partial<StoredSlotPresentation> | undefined {
|
||||
const raw = body.presentation;
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
const mode = value.backgroundMode;
|
||||
if (
|
||||
mode !== undefined &&
|
||||
mode !== null &&
|
||||
mode !== "blurred" &&
|
||||
mode !== "solid" &&
|
||||
mode !== "black"
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
|
||||
}
|
||||
return {
|
||||
...(mode === undefined
|
||||
? {}
|
||||
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
|
||||
...(value.backgroundColor === undefined
|
||||
? {}
|
||||
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
|
||||
...(value.backgroundDarkened === undefined
|
||||
? {}
|
||||
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
|
||||
...(value.blurPixels === undefined
|
||||
? {}
|
||||
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
|
||||
...(value.foregroundShadow === undefined
|
||||
? {}
|
||||
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
|
||||
const value = body[field];
|
||||
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/timelines",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelines = await store(context).listTimelines();
|
||||
return Response.json({
|
||||
timelines: timelines.map((timeline) => ({
|
||||
description: timeline.description,
|
||||
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
|
||||
id: timeline.id,
|
||||
name: timeline.name,
|
||||
revision: timeline.revision,
|
||||
updatedAt: timeline.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await createTimeline({
|
||||
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
|
||||
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
|
||||
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
|
||||
name: requiredString(body, "name", {
|
||||
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||
}),
|
||||
store: store(context),
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
return Response.json(describe(await requireTimeline(store(context), timelineId)));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await updateTimeline({
|
||||
changes: {
|
||||
...(body.name === undefined
|
||||
? {}
|
||||
: {
|
||||
name: requiredString(body, "name", {
|
||||
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||
}),
|
||||
}),
|
||||
...(body.description === undefined
|
||||
? {}
|
||||
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
|
||||
...(body.eligibleFrom === undefined
|
||||
? {}
|
||||
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||
...(body.eligibleUntil === undefined
|
||||
? {}
|
||||
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||
},
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
await deleteTimeline({ store: store(context), timelineId });
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/duplicate",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await duplicateTimeline({
|
||||
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/sources",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await addProviderSource({
|
||||
label: requiredString(body, "label", { maxLength: 200 }),
|
||||
providerId: requireOpaqueId(body, "providerId"),
|
||||
sourceId: requireOpaqueId(body, "sourceId"),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId/sources/:sourceId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const sourceId = requireUuidParam(context, "sourceId");
|
||||
return Response.json(
|
||||
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/slots",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const sourceId = body.sourceId;
|
||||
const detail = await addSlot({
|
||||
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||
}),
|
||||
eligibleFrom: optionalInstant(body, "eligibleFrom"),
|
||||
eligibleUntil: optionalInstant(body, "eligibleUntil"),
|
||||
enabled: optionalBoolean(body, "enabled") ?? true,
|
||||
...(sourceId === undefined
|
||||
? {}
|
||||
: {
|
||||
source: {
|
||||
sourceId: isUuid(sourceId)
|
||||
? sourceId
|
||||
: (() => {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
...(body.itemId === undefined
|
||||
? {}
|
||||
: {
|
||||
pinned: {
|
||||
itemId: requireOpaqueId(body, "itemId"),
|
||||
providerId: requireOpaqueId(body, "providerId"),
|
||||
},
|
||||
}),
|
||||
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
|
||||
presentation: readPresentation(body),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/timelines/:timelineId/slots/:slotId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const slotId = requireUuidParam(context, "slotId");
|
||||
const body = await readJsonObject(request);
|
||||
const presentation = readPresentation(body);
|
||||
const detail = await updateSlot({
|
||||
changes: {
|
||||
...(body.enabled === undefined
|
||||
? {}
|
||||
: { enabled: optionalBoolean(body, "enabled") ?? true }),
|
||||
...(body.durationOverrideMs === undefined
|
||||
? {}
|
||||
: {
|
||||
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||
}) as number | null,
|
||||
}),
|
||||
...(body.eligibleFrom === undefined
|
||||
? {}
|
||||
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||
...(body.eligibleUntil === undefined
|
||||
? {}
|
||||
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
|
||||
...(presentation === undefined ? {} : { presentation }),
|
||||
},
|
||||
slotId,
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId/slots/:slotId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const slotId = requireUuidParam(context, "slotId");
|
||||
return Response.json(
|
||||
describe(await removeSlot({ slotId, store: store(context), timelineId })),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PUT",
|
||||
path: "/timelines/:timelineId/slot-order",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 16_384,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const ids = body.slotIds;
|
||||
if (
|
||||
!Array.isArray(ids) ||
|
||||
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
|
||||
!ids.every((id) => isUuid(id))
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
|
||||
}
|
||||
return Response.json(
|
||||
describe(
|
||||
await reorderSlots({
|
||||
orderedSlotIds: ids as string[],
|
||||
store: store(context),
|
||||
timelineId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { buildDisplayChannelState } from "../display/state.js";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
optionalBoolean,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonObject,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import type { SignagePlaybackService } from "../playback/service.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import { readSignageSettings } from "../settings.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import type { SignageStore, StoredChannel } from "../store/types.js";
|
||||
import {
|
||||
describeViewerHealth,
|
||||
openViewerSession,
|
||||
recordViewerHeartbeat,
|
||||
SIGNAGE_VIEWER_NAME_MAX,
|
||||
updateViewerAssignment,
|
||||
} from "../viewers/service.js";
|
||||
|
||||
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||
return createSignageStore(context.services.database);
|
||||
}
|
||||
|
||||
/**
|
||||
* The safe Core device identifier, when the caller is a paired display.
|
||||
*
|
||||
* A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats
|
||||
* it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing,
|
||||
* revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or
|
||||
* forwards one.
|
||||
*/
|
||||
function deviceIdOf(context: ModuleRouteHandlerContext): string | null {
|
||||
return context.principal.kind === "device" ? context.principal.deviceId : null;
|
||||
}
|
||||
|
||||
async function requireChannel(
|
||||
signageStore: SignageStore,
|
||||
channelId: string,
|
||||
): Promise<StoredChannel> {
|
||||
const groups = await signageStore.listChannelGroups();
|
||||
for (const group of groups) {
|
||||
const channel = group.channels.find((candidate) => candidate.id === channelId);
|
||||
if (channel) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
export function registerViewerRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
playback: () => SignagePlaybackService,
|
||||
registry: () => SignageProviderRegistry,
|
||||
): void {
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/viewers/session",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 2048,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const viewerKey = body.viewerKey;
|
||||
if (typeof viewerKey !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||
}
|
||||
// A display registers with the identity it keeps in its own IndexedDB and lands on the
|
||||
// default channel. It never chooses one: a public screen that could would let anyone who
|
||||
// reaches the page decide what a wall shows (ADR 0004).
|
||||
const session = await openViewerSession({
|
||||
at: new Date(),
|
||||
deviceId: deviceIdOf(context),
|
||||
store: store(context),
|
||||
viewerKey,
|
||||
});
|
||||
return Response.json({
|
||||
channelId: session.channel.id,
|
||||
channelKey: session.channel.key,
|
||||
groupId: session.groupId,
|
||||
heartbeatIntervalMs: 12_000,
|
||||
viewerId: session.viewer.id,
|
||||
viewerName: session.viewer.name,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/viewers/:viewerId/heartbeat",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const body = await readJsonObject(request);
|
||||
const settings = readSignageSettings(context.config);
|
||||
const rawError = body.error;
|
||||
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
||||
// local cursor is never accepted as truth.
|
||||
const result = await recordViewerHeartbeat({
|
||||
at: new Date(),
|
||||
heartbeat: {
|
||||
cachedAssetCount:
|
||||
optionalInteger(body, "cachedAssetCount", {
|
||||
maximum: 100_000,
|
||||
minimum: 0,
|
||||
}) ?? undefined,
|
||||
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
|
||||
reportedContributionId:
|
||||
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
|
||||
reportedPlaybackRevision:
|
||||
optionalInteger(body, "reportedPlaybackRevision", {
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
minimum: 0,
|
||||
}) ?? null,
|
||||
reportedProviderId:
|
||||
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
|
||||
reportedSelectionId:
|
||||
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
|
||||
reportedTimelineId:
|
||||
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
|
||||
...(rawError === undefined
|
||||
? {}
|
||||
: {
|
||||
error:
|
||||
rawError === null
|
||||
? null
|
||||
: {
|
||||
code: readErrorCode(rawError),
|
||||
detail: readErrorDetail(rawError),
|
||||
},
|
||||
}),
|
||||
},
|
||||
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
||||
store: store(context),
|
||||
viewerId,
|
||||
});
|
||||
// The response says what a display needs to keep itself honest, and nothing about other
|
||||
// displays or the installation.
|
||||
return Response.json({ acknowledged: true, recorded: result.written });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/channels/:channelId/display-state",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const channelId = requireUuidParam(context, "channelId");
|
||||
const signageStore = store(context);
|
||||
const channel = await requireChannel(signageStore, channelId);
|
||||
const snapshot = await playback().snapshot(channel.groupId);
|
||||
return Response.json(
|
||||
await buildDisplayChannelState({
|
||||
atMs: Date.now(),
|
||||
channel,
|
||||
playback: snapshot,
|
||||
registry: registry(),
|
||||
settings: readSignageSettings(context.config),
|
||||
storage: context.storage,
|
||||
store: signageStore,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/viewers",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const atMs = Date.now();
|
||||
const viewers = await store(context).listViewers();
|
||||
return Response.json({
|
||||
viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/viewers/:viewerId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const body = await readJsonObject(request);
|
||||
const channelId = body.channelId;
|
||||
if (channelId !== undefined && typeof channelId !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" });
|
||||
}
|
||||
const viewer = await updateViewerAssignment({
|
||||
...(channelId === undefined ? {} : { channelId }),
|
||||
name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }),
|
||||
store: store(context),
|
||||
viewerId,
|
||||
});
|
||||
return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/viewers/:viewerId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const signageStore = store(context);
|
||||
if (!(await signageStore.findViewer(viewerId))) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
// Removing a screen from the roster is a record-keeping action. A display that checks in
|
||||
// again is simply registered afresh, because its own identity is the one it keeps.
|
||||
await signageStore.deleteViewer(viewerId);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readErrorCode(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
|
||||
}
|
||||
const code = (value as { code?: unknown }).code;
|
||||
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function readErrorDetail(value: unknown): string | null {
|
||||
const detail = (value as { detail?: unknown }).detail;
|
||||
if (detail === undefined || detail === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof detail !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
defineModuleRuntime,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
} from "@ekklesia/module-sdk";
|
||||
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
|
||||
import {
|
||||
SIGNAGE_IMAGE_MAX_BYTES,
|
||||
SIGNAGE_IMAGE_MAX_PIXELS,
|
||||
SIGNAGE_UPLOAD_BATCH_MAX_FILES,
|
||||
SIGNAGE_UPLOAD_CONCURRENCY,
|
||||
} from "./images/limits.js";
|
||||
import { createPlaybackCapability } from "./playback/capability.js";
|
||||
import {
|
||||
createPlaybackService,
|
||||
type PlaybackSnapshot,
|
||||
type SignagePlaybackService,
|
||||
} from "./playback/service.js";
|
||||
import type { SignageProviderHandle } from "./providers/contract.js";
|
||||
import {
|
||||
createNativeImageProvider,
|
||||
type NativeImageProvider,
|
||||
} from "./providers/native-provider.js";
|
||||
import { createProviderRegistry, type SignageProviderRegistry } from "./providers/registry.js";
|
||||
import { registerImageRoutes } from "./routes/images.js";
|
||||
import { registerPlaybackRoutes } from "./routes/playback.js";
|
||||
import { registerProviderRoutes } from "./routes/providers.js";
|
||||
import { registerTimelineRoutes } from "./routes/timelines.js";
|
||||
import { registerViewerRoutes } from "./routes/viewers.js";
|
||||
import { readSignageSettings } from "./settings.js";
|
||||
import { createSignageStore } from "./store/drizzle.js";
|
||||
import { createDisplaysCapability } from "./viewers/capability.js";
|
||||
|
||||
/**
|
||||
* Signage's server runtime, as a factory rather than a module-level singleton.
|
||||
*
|
||||
* A factory because the runtime owns real state — the live provider registry, the built-in image
|
||||
* provider's registration handle, the playback service and its timers — and a test that exercised
|
||||
* registration twice against one shared instance would be testing leftovers. `index.ts` calls this
|
||||
* once; a test calls it per case.
|
||||
*
|
||||
* Registration stays declarative and I/O-free: nothing here connects to PostgreSQL, contacts object
|
||||
* storage, reads the environment, opens a socket, or applies a migration. Live services reach
|
||||
* Signage only through the contexts the platform hands `start`, `stop`, and route handlers.
|
||||
*/
|
||||
export interface SignageRuntimeOptions {
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
export interface SignageModuleRuntime {
|
||||
readonly definition: ModuleRuntimeDefinition;
|
||||
/** The live registry, exposed for tests and for the routes that report provider health. */
|
||||
readonly registry: SignageProviderRegistry;
|
||||
}
|
||||
|
||||
/** Realtime carries compact authoritative state and revisions, never content (ADR 0005). Large
|
||||
* state — a timeline, a history, a renderer payload — is fetched over authorized HTTP keyed by
|
||||
* revision, which closes the GET-then-subscribe race without a second transport. */
|
||||
export const SIGNAGE_DISPLAY_CHANNEL = "display-state";
|
||||
export const SIGNAGE_MANAGEMENT_CHANNEL = "management-state";
|
||||
|
||||
export function createSignageModuleRuntime(
|
||||
options: SignageRuntimeOptions = {},
|
||||
): SignageModuleRuntime {
|
||||
const now = options.now ?? (() => new Date());
|
||||
let registry: SignageProviderRegistry | undefined;
|
||||
let nativeProvider: NativeImageProvider | undefined;
|
||||
let nativeHandle: SignageProviderHandle | undefined;
|
||||
let playback: SignagePlaybackService | undefined;
|
||||
let store: ReturnType<typeof createSignageStore> | undefined;
|
||||
/** The latest snapshot per group, so a publication never has to read the database from inside the
|
||||
* very lock that produced it. */
|
||||
const snapshots = new Map<string, PlaybackSnapshot>();
|
||||
|
||||
function requireRegistry(): SignageProviderRegistry {
|
||||
if (!registry) {
|
||||
throw new Error("Signage's provider registry is only available after registration.");
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function requirePlayback(): SignagePlaybackService {
|
||||
if (!playback) {
|
||||
throw new Error("Signage's playback service is only available after start.");
|
||||
}
|
||||
return playback;
|
||||
}
|
||||
|
||||
function requireStore(): ReturnType<typeof createSignageStore> {
|
||||
if (!store) {
|
||||
throw new Error("Signage's database facade is only available after start.");
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function publish(context: ModuleRuntimeContext): void {
|
||||
const groups = [...snapshots.values()].sort((left, right) =>
|
||||
left.groupId.localeCompare(right.groupId),
|
||||
);
|
||||
try {
|
||||
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
|
||||
groups,
|
||||
serverNowMs: now().getTime(),
|
||||
});
|
||||
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
|
||||
// Revisions only. The dashboard refetches what changed over authorized HTTP.
|
||||
groups: groups.map((snapshot) => ({
|
||||
groupId: snapshot.groupId,
|
||||
revision: snapshot.revision,
|
||||
selectionRevision: snapshot.selectionRevision,
|
||||
transport: snapshot.transport,
|
||||
})),
|
||||
providers: requireRegistry()
|
||||
.health()
|
||||
.map((provider) => ({
|
||||
consecutiveFailures: provider.consecutiveFailures,
|
||||
lastFailureCode: provider.lastFailureCode,
|
||||
providerId: provider.providerId,
|
||||
})),
|
||||
serverNowMs: now().getTime(),
|
||||
});
|
||||
} catch {
|
||||
// Realtime is best effort and never blocks or changes committed playback state.
|
||||
}
|
||||
}
|
||||
|
||||
const definition = defineModuleRuntime({
|
||||
id: "signage",
|
||||
version: "0.1.0",
|
||||
|
||||
register({ capabilities, config, http, realtime }) {
|
||||
const settings = readSignageSettings(config);
|
||||
|
||||
registry = createProviderRegistry({
|
||||
now,
|
||||
onInvalidate: (notice) => {
|
||||
// A notification, never a command: playback decides what to refresh and when, because it
|
||||
// is the only thing that knows what is on screen.
|
||||
void playback?.invalidate(notice).catch(() => undefined);
|
||||
},
|
||||
timeoutMs: settings.providerTimeoutMs,
|
||||
});
|
||||
|
||||
// The extension seam every future content module reaches Signage through (ADR 0002).
|
||||
capabilities.provide("signage.content-providers", registry.capability);
|
||||
|
||||
// The browser half of the same seam. Nothing registers against it server-side: a contribution
|
||||
// is declared in a *contributing* module's manifest and published by the platform, and this
|
||||
// declaration is what makes `capabilityRange` mean something. Resolving it tells a module
|
||||
// which version of Signage's renderer input contract this installation speaks.
|
||||
capabilities.provide(
|
||||
"signage.slide-renderer",
|
||||
Object.freeze({ inputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION }),
|
||||
);
|
||||
|
||||
// Sanitized playback state and commands for a future consumer such as an Ekklesia Companion.
|
||||
// Exactly what an operator has in the dashboard, over the same serialized service — and
|
||||
// deliberately no transport, topic, broker, or integration vocabulary of any kind.
|
||||
capabilities.provide(
|
||||
"signage.playback",
|
||||
createPlaybackCapability(requirePlayback, async () => {
|
||||
const groups = await requireStore().listChannelGroups();
|
||||
return groups.map((group) => group.group.id);
|
||||
}),
|
||||
);
|
||||
|
||||
// Sanitized viewer and channel health, and assignment, for another module. Names, online
|
||||
// state, and what a screen last reported — never a credential, a pairing token, or a host
|
||||
// internal.
|
||||
capabilities.provide("signage.displays", createDisplaysCapability(requireStore, now));
|
||||
|
||||
realtime.channel({ channel: SIGNAGE_DISPLAY_CHANNEL, permission: "signage.display.view" });
|
||||
realtime.channel({ channel: SIGNAGE_MANAGEMENT_CHANNEL, permission: "signage.view" });
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/settings",
|
||||
permission: "signage.view",
|
||||
// A GET route accepts no request body at all.
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
const current = readSignageSettings(context.config);
|
||||
// Operator settings and Signage's own declared limits. Signage declares no `secret`
|
||||
// configuration key, and this route would still never serialize one: what an operator may
|
||||
// see of a module's configuration is the module's deliberate decision, value by value.
|
||||
//
|
||||
// The limits travel with the settings so the dashboard never hardcodes a number the
|
||||
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||
return Response.json({
|
||||
crossfadeMs: current.crossfadeMs,
|
||||
moduleVersion: "0.1.0",
|
||||
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||
nativePresentation: current.nativePresentation,
|
||||
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||
timezone: current.timezone,
|
||||
uploads: {
|
||||
batchMaxFiles: SIGNAGE_UPLOAD_BATCH_MAX_FILES,
|
||||
concurrency: SIGNAGE_UPLOAD_CONCURRENCY,
|
||||
maxBytes: SIGNAGE_IMAGE_MAX_BYTES,
|
||||
maxPixels: SIGNAGE_IMAGE_MAX_PIXELS,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registerImageRoutes(http);
|
||||
registerProviderRoutes(http, requireRegistry);
|
||||
registerTimelineRoutes(http);
|
||||
registerPlaybackRoutes(http, requirePlayback);
|
||||
registerViewerRoutes(http, requirePlayback, requireRegistry);
|
||||
},
|
||||
|
||||
async start(context: ModuleRuntimeContext) {
|
||||
const settings = readSignageSettings(context.config);
|
||||
store = createSignageStore(context.services.database);
|
||||
|
||||
// Signage's own content provider registers through the identical capability an external
|
||||
// provider uses. There is one selection pipeline, not a native special case (ADR 0001).
|
||||
nativeProvider = createNativeImageProvider({
|
||||
defaultDurationMs: settings.nativeImageDurationMs,
|
||||
now,
|
||||
store,
|
||||
});
|
||||
nativeHandle = requireRegistry().capability.register(nativeProvider.registration);
|
||||
|
||||
playback = createPlaybackService({
|
||||
now: () => now().getTime(),
|
||||
onSnapshot: (snapshot) => {
|
||||
snapshots.set(snapshot.groupId, snapshot);
|
||||
publish(context);
|
||||
},
|
||||
registry: requireRegistry(),
|
||||
settings,
|
||||
store,
|
||||
});
|
||||
|
||||
// One default channel group with one default channel, and nothing else. `in-person` and
|
||||
// `online` are an administrator's decision, not an install-time one (ADR 0004).
|
||||
await playback.ensureDefaultGroup();
|
||||
await playback.start();
|
||||
|
||||
// Every declared channel must receive an initial publication before `start` completes, so a
|
||||
// subscriber that connects immediately is given state rather than silence.
|
||||
publish(context);
|
||||
},
|
||||
|
||||
stop() {
|
||||
// Idempotent, and safe after a `start` that failed partway: `stop` runs before rollback, and
|
||||
// may run again during reverse-order shutdown.
|
||||
playback?.stop();
|
||||
playback = undefined;
|
||||
store = undefined;
|
||||
snapshots.clear();
|
||||
nativeHandle?.unregister();
|
||||
nativeHandle = undefined;
|
||||
nativeProvider = undefined;
|
||||
registry?.clear();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
definition,
|
||||
get registry(): SignageProviderRegistry {
|
||||
return requireRegistry();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ModuleConfiguration } from "@ekklesia/module-sdk";
|
||||
import {
|
||||
isSignageBackgroundMode,
|
||||
SIGNAGE_DEFAULT_SETTINGS,
|
||||
SIGNAGE_HEX_COLOR,
|
||||
type SignageSettings,
|
||||
} from "../shared/settings.js";
|
||||
|
||||
/**
|
||||
* Reads Signage's own declared configuration through the one platform contract that offers it.
|
||||
*
|
||||
* Signage never reads `process.env`, never queries a platform table for a setting, and has no way to
|
||||
* see another module's configuration — which is what keeps it runnable under a future standalone
|
||||
* host that stores configuration somewhere else entirely.
|
||||
*
|
||||
* The manifest declares a default for every key, so in a real deployment each accessor is guaranteed
|
||||
* a value before any Signage code runs. The fallbacks exist because the accessors are typed
|
||||
* `T | undefined` for keys a manifest does not declare, and because a module-layer test may
|
||||
* construct a `ModuleConfiguration` by hand.
|
||||
*/
|
||||
export function readSignageSettings(config: ModuleConfiguration): SignageSettings {
|
||||
const defaults = SIGNAGE_DEFAULT_SETTINGS;
|
||||
const backgroundColor = config.getString("defaultBackgroundColor");
|
||||
const backgroundMode = config.getEnum("defaultBackgroundMode");
|
||||
const durationSeconds = config.getInteger("nativeImageDurationSeconds");
|
||||
const heartbeatSeconds = config.getInteger("viewerHeartbeatWriteSeconds");
|
||||
return Object.freeze({
|
||||
crossfadeMs: config.getInteger("crossfadeMilliseconds") ?? defaults.crossfadeMs,
|
||||
nativeImageDurationMs:
|
||||
durationSeconds === undefined ? defaults.nativeImageDurationMs : durationSeconds * 1000,
|
||||
nativePresentation: Object.freeze({
|
||||
backgroundColor:
|
||||
backgroundColor !== undefined && SIGNAGE_HEX_COLOR.test(backgroundColor)
|
||||
? backgroundColor.toLowerCase()
|
||||
: defaults.nativePresentation.backgroundColor,
|
||||
backgroundDarkened:
|
||||
config.getBoolean("defaultBackgroundDarkened") ??
|
||||
defaults.nativePresentation.backgroundDarkened,
|
||||
backgroundMode: isSignageBackgroundMode(backgroundMode)
|
||||
? backgroundMode
|
||||
: defaults.nativePresentation.backgroundMode,
|
||||
blurPixels: config.getInteger("defaultBlurPixels") ?? defaults.nativePresentation.blurPixels,
|
||||
}),
|
||||
playbackHistoryLimit:
|
||||
config.getInteger("playbackHistoryLimit") ?? defaults.playbackHistoryLimit,
|
||||
providerTimeoutMs:
|
||||
config.getInteger("providerTimeoutMilliseconds") ?? defaults.providerTimeoutMs,
|
||||
timezone: config.getString("timezone") ?? defaults.timezone,
|
||||
viewerHeartbeatWriteMs:
|
||||
heartbeatSeconds === undefined ? defaults.viewerHeartbeatWriteMs : heartbeatSeconds * 1000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
|
||||
import {
|
||||
channelGroups,
|
||||
channels,
|
||||
playbackStates,
|
||||
selections,
|
||||
selectionVariants,
|
||||
} from "../../schema/tables.js";
|
||||
import {
|
||||
type NewStoredSelection,
|
||||
type PlaybackChanges,
|
||||
type SelectionChanges,
|
||||
type SignagePlaybackStore,
|
||||
SignageStoreConflict,
|
||||
type StoredChannel,
|
||||
type StoredChannelGroupDetail,
|
||||
type StoredPlaybackState,
|
||||
type StoredSelection,
|
||||
type StoredSelectionVariant,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* The playback half of Signage's production store.
|
||||
*
|
||||
* Two things here are transactional because they cannot be safe otherwise: creating a group together
|
||||
* with its playback row and its default channel, and taking the next selection sequence. A group
|
||||
* without a default channel has nowhere for a viewer to land, and two selections that took the same
|
||||
* sequence would make history ambiguous exactly where `previous` reads it.
|
||||
*/
|
||||
|
||||
type SelectionRow = typeof selections.$inferSelect;
|
||||
|
||||
function toPlayback(row: typeof playbackStates.$inferSelect): StoredPlaybackState {
|
||||
return {
|
||||
activeTimelineId: row.activeTimelineId,
|
||||
blackout: row.blackout,
|
||||
currentSelectionId: row.currentSelectionId,
|
||||
currentSlotId: row.currentSlotId,
|
||||
groupId: row.groupId,
|
||||
nextSequence: row.nextSequence,
|
||||
paused: row.paused,
|
||||
pausedRemainingMs: row.pausedRemainingMs,
|
||||
pendingRequestedAt: row.pendingRequestedAt,
|
||||
pendingTimelineId: row.pendingTimelineId,
|
||||
revision: row.revision,
|
||||
scheduledActivationAt: row.scheduledActivationAt,
|
||||
scheduledTimelineId: row.scheduledTimelineId,
|
||||
slotDeadlineAt: row.slotDeadlineAt,
|
||||
slotStartedAt: row.slotStartedAt,
|
||||
staticAssetId: row.staticAssetId,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toChannel(row: typeof channels.$inferSelect): StoredChannel {
|
||||
return {
|
||||
groupId: row.groupId,
|
||||
id: row.id,
|
||||
isDefault: row.isDefault,
|
||||
key: row.key,
|
||||
name: row.name,
|
||||
};
|
||||
}
|
||||
|
||||
function toSelection(
|
||||
row: SelectionRow,
|
||||
variants: readonly StoredSelectionVariant[],
|
||||
): StoredSelection {
|
||||
return {
|
||||
availableUntil: row.availableUntil,
|
||||
deadlineAt: row.deadlineAt,
|
||||
durationMs: row.durationMs,
|
||||
groupId: row.groupId,
|
||||
id: row.id,
|
||||
itemId: row.itemId,
|
||||
itemRevision: row.itemRevision,
|
||||
presentation: row.presentation,
|
||||
providerId: row.providerId,
|
||||
rendererContributionId: row.rendererContributionId,
|
||||
rendererContributionVersion: row.rendererContributionVersion,
|
||||
resolvedAt: row.resolvedAt,
|
||||
revision: row.revision,
|
||||
selectionScope: row.selectionScope,
|
||||
sequence: row.sequence,
|
||||
slotId: row.slotId,
|
||||
stale: row.stale,
|
||||
startedAt: row.startedAt,
|
||||
timelineId: row.timelineId,
|
||||
variants,
|
||||
withdrawn: row.withdrawn,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore {
|
||||
async function variantsFor(
|
||||
selectionIds: readonly string[],
|
||||
): Promise<Map<string, StoredSelectionVariant[]>> {
|
||||
const grouped = new Map<string, StoredSelectionVariant[]>();
|
||||
if (selectionIds.length === 0) {
|
||||
return grouped;
|
||||
}
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(selectionVariants)
|
||||
.where(inArray(selectionVariants.selectionId, [...selectionIds]));
|
||||
for (const row of rows) {
|
||||
const list = grouped.get(row.selectionId) ?? [];
|
||||
list.push({ audienceKey: row.audienceKey, isDefault: row.isDefault, payload: row.payload });
|
||||
grouped.set(row.selectionId, list);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function hydrateSelections(rows: readonly SelectionRow[]): Promise<StoredSelection[]> {
|
||||
const variants = await variantsFor(rows.map((row) => row.id));
|
||||
return rows.map((row) => toSelection(row, variants.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
async function groupDetail(
|
||||
groupRow: typeof channelGroups.$inferSelect,
|
||||
): Promise<StoredChannelGroupDetail | undefined> {
|
||||
const [channelRows, playbackRows] = await Promise.all([
|
||||
database
|
||||
.select()
|
||||
.from(channels)
|
||||
.where(eq(channels.groupId, groupRow.id))
|
||||
.orderBy(desc(channels.isDefault), asc(channels.key)),
|
||||
database.select().from(playbackStates).where(eq(playbackStates.groupId, groupRow.id)),
|
||||
]);
|
||||
const playback = playbackRows[0];
|
||||
if (!playback) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
channels: channelRows.map(toChannel),
|
||||
group: { id: groupRow.id, name: groupRow.name, revision: groupRow.revision },
|
||||
playback: toPlayback(playback),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async addChannel(channel: StoredChannel): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(channels).values({
|
||||
groupId: channel.groupId,
|
||||
id: channel.id,
|
||||
isDefault: channel.isDefault,
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
await transaction
|
||||
.update(channelGroups)
|
||||
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
|
||||
.where(eq(channelGroups.id, channel.groupId));
|
||||
});
|
||||
},
|
||||
|
||||
async createChannelGroup(input: {
|
||||
readonly groupId: string;
|
||||
readonly name: string;
|
||||
readonly channelId: string;
|
||||
readonly channelName: string;
|
||||
}): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(channelGroups).values({ id: input.groupId, name: input.name });
|
||||
await transaction.insert(channels).values({
|
||||
groupId: input.groupId,
|
||||
id: input.channelId,
|
||||
isDefault: true,
|
||||
key: "default",
|
||||
name: input.channelName,
|
||||
});
|
||||
await transaction.insert(playbackStates).values({ groupId: input.groupId });
|
||||
});
|
||||
},
|
||||
|
||||
async deleteChannelGroup(groupId: string): Promise<void> {
|
||||
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
|
||||
},
|
||||
|
||||
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||
const rows = await database.select().from(channelGroups).where(eq(channelGroups.id, groupId));
|
||||
const row = rows[0];
|
||||
return row ? groupDetail(row) : undefined;
|
||||
},
|
||||
|
||||
async findLatestSelectionForScope(
|
||||
groupId: string,
|
||||
selectionScope: string,
|
||||
): Promise<StoredSelection | undefined> {
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(selections)
|
||||
.where(and(eq(selections.groupId, groupId), eq(selections.selectionScope, selectionScope)))
|
||||
.orderBy(desc(selections.sequence))
|
||||
.limit(1);
|
||||
return (await hydrateSelections(rows))[0];
|
||||
},
|
||||
|
||||
async findSelection(selectionId: string): Promise<StoredSelection | undefined> {
|
||||
const rows = await database.select().from(selections).where(eq(selections.id, selectionId));
|
||||
return (await hydrateSelections(rows))[0];
|
||||
},
|
||||
|
||||
async insertSelection(selection: NewStoredSelection): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(selections).values({
|
||||
availableUntil: selection.availableUntil,
|
||||
deadlineAt: selection.deadlineAt,
|
||||
durationMs: selection.durationMs,
|
||||
groupId: selection.groupId,
|
||||
id: selection.id,
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
presentation: selection.presentation,
|
||||
providerId: selection.providerId,
|
||||
rendererContributionId: selection.rendererContributionId,
|
||||
rendererContributionVersion: selection.rendererContributionVersion,
|
||||
selectionScope: selection.selectionScope,
|
||||
sequence: selection.sequence,
|
||||
slotId: selection.slotId,
|
||||
stale: selection.stale,
|
||||
startedAt: selection.startedAt,
|
||||
timelineId: selection.timelineId,
|
||||
withdrawn: selection.withdrawn,
|
||||
});
|
||||
if (selection.variants.length > 0) {
|
||||
await transaction.insert(selectionVariants).values(
|
||||
selection.variants.map((variant) => ({
|
||||
audienceKey: variant.audienceKey,
|
||||
id: randomUUID(),
|
||||
isDefault: variant.isDefault,
|
||||
payload: variant.payload,
|
||||
selectionId: selection.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
|
||||
const rows = await database.select().from(channelGroups).orderBy(asc(channelGroups.name));
|
||||
const details = await Promise.all(rows.map((row) => groupDetail(row)));
|
||||
return details.filter((detail): detail is StoredChannelGroupDetail => detail !== undefined);
|
||||
},
|
||||
|
||||
async listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(selections)
|
||||
.where(eq(selections.groupId, groupId))
|
||||
.orderBy(desc(selections.sequence))
|
||||
.limit(limit);
|
||||
return hydrateSelections(rows);
|
||||
},
|
||||
|
||||
async pruneSelections(groupId: string, limit: number): Promise<void> {
|
||||
const keep = await database
|
||||
.select({ sequence: selections.sequence })
|
||||
.from(selections)
|
||||
.where(eq(selections.groupId, groupId))
|
||||
.orderBy(desc(selections.sequence))
|
||||
.limit(limit);
|
||||
const oldest = keep.at(-1)?.sequence;
|
||||
if (oldest === undefined) {
|
||||
return;
|
||||
}
|
||||
await database
|
||||
.delete(selections)
|
||||
.where(and(eq(selections.groupId, groupId), lt(selections.sequence, oldest)));
|
||||
},
|
||||
|
||||
async removeChannel(channelId: string): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const rows = await transaction.select().from(channels).where(eq(channels.id, channelId));
|
||||
const channel = rows[0];
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
if (channel.isDefault) {
|
||||
// Something must remain for a viewer to land on.
|
||||
throw new SignageStoreConflict("channel.default");
|
||||
}
|
||||
await transaction.delete(channels).where(eq(channels.id, channelId));
|
||||
await transaction
|
||||
.update(channelGroups)
|
||||
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
|
||||
.where(eq(channelGroups.id, channel.groupId));
|
||||
});
|
||||
},
|
||||
|
||||
async takeSelectionSequence(groupId: string): Promise<number> {
|
||||
const rows = await database
|
||||
.update(playbackStates)
|
||||
.set({ nextSequence: sql`${playbackStates.nextSequence} + 1` })
|
||||
.where(eq(playbackStates.groupId, groupId))
|
||||
.returning({ nextSequence: playbackStates.nextSequence });
|
||||
const next = rows[0]?.nextSequence;
|
||||
if (next === undefined) {
|
||||
throw new SignageStoreConflict("playback.sequence");
|
||||
}
|
||||
// `returning` gives the incremented value, so the sequence this call owns is one below it.
|
||||
return next - 1;
|
||||
},
|
||||
|
||||
async updatePlayback(
|
||||
groupId: string,
|
||||
changes: PlaybackChanges,
|
||||
options?: { readonly bumpRevision?: boolean },
|
||||
): Promise<void> {
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value !== undefined) {
|
||||
values[key] = value;
|
||||
} else if (Object.hasOwn(changes, key)) {
|
||||
values[key] = null;
|
||||
}
|
||||
}
|
||||
if (options?.bumpRevision !== false) {
|
||||
values.revision = sql`${playbackStates.revision} + 1`;
|
||||
}
|
||||
await database.update(playbackStates).set(values).where(eq(playbackStates.groupId, groupId));
|
||||
},
|
||||
|
||||
async updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"availableUntil",
|
||||
"deadlineAt",
|
||||
"durationMs",
|
||||
"itemRevision",
|
||||
"presentation",
|
||||
"stale",
|
||||
"startedAt",
|
||||
"withdrawn",
|
||||
] as const) {
|
||||
if (changes[key] !== undefined) {
|
||||
values[key] = changes[key];
|
||||
}
|
||||
}
|
||||
if (changes.bumpRevision === true) {
|
||||
values.revision = sql`${selections.revision} + 1`;
|
||||
}
|
||||
if (Object.keys(values).length > 0) {
|
||||
await transaction.update(selections).set(values).where(eq(selections.id, selectionId));
|
||||
}
|
||||
if (changes.variants) {
|
||||
// Replaced wholesale: a partial update would leave one audience rendering a payload from
|
||||
// a revision that no longer exists.
|
||||
await transaction
|
||||
.delete(selectionVariants)
|
||||
.where(eq(selectionVariants.selectionId, selectionId));
|
||||
if (changes.variants.length > 0) {
|
||||
await transaction.insert(selectionVariants).values(
|
||||
changes.variants.map((variant) => ({
|
||||
audienceKey: variant.audienceKey,
|
||||
id: randomUUID(),
|
||||
isDefault: variant.isDefault,
|
||||
payload: variant.payload,
|
||||
selectionId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { asc, eq, or, sql } from "drizzle-orm";
|
||||
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
|
||||
import {
|
||||
type NewStoredProviderSource,
|
||||
type NewStoredSlot,
|
||||
type NewStoredTimeline,
|
||||
SignageStoreConflict,
|
||||
type SignageTimelineStore,
|
||||
type SlotChanges,
|
||||
type StoredProviderSource,
|
||||
type StoredSlot,
|
||||
type StoredTimeline,
|
||||
type StoredTimelineDetail,
|
||||
type StoredTimelineUsage,
|
||||
type TimelineChanges,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* The timeline half of Signage's production store.
|
||||
*
|
||||
* Two invariants live here rather than in the services above, because they are only safe if they are
|
||||
* transactional:
|
||||
*
|
||||
* - **Slot positions stay unique and contiguous from zero.** Every operation that can change the
|
||||
* order rewrites the whole order inside one transaction, so a gap can never be observed — not even
|
||||
* briefly, by a reconciliation running at the same moment.
|
||||
* - **A timeline's revision increments exactly once per semantic change**, in the same transaction
|
||||
* as the change, so a client that compares revisions can never see a changed timeline at an
|
||||
* unchanged revision.
|
||||
*/
|
||||
|
||||
type TimelineRow = typeof timelines.$inferSelect;
|
||||
type SlotRow = typeof timelineSlots.$inferSelect;
|
||||
type SourceRow = typeof providerSources.$inferSelect;
|
||||
|
||||
function toTimeline(row: TimelineRow): StoredTimeline {
|
||||
return {
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
eligibleFrom: row.eligibleFrom,
|
||||
eligibleUntil: row.eligibleUntil,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
revision: row.revision,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSource(row: SourceRow): StoredProviderSource {
|
||||
return {
|
||||
createdAt: row.createdAt,
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
providerId: row.providerId,
|
||||
selectionScope: row.selectionScope,
|
||||
sourceId: row.sourceId,
|
||||
timelineId: row.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
function toSlot(row: SlotRow): StoredSlot {
|
||||
return {
|
||||
durationOverrideMs: row.durationOverrideMs,
|
||||
eligibleFrom: row.eligibleFrom,
|
||||
eligibleUntil: row.eligibleUntil,
|
||||
enabled: row.enabled,
|
||||
id: row.id,
|
||||
itemId: row.itemId,
|
||||
kind: row.kind === "pinned-item" ? "pinned-item" : "provider-source",
|
||||
position: row.position,
|
||||
presentation: {
|
||||
backgroundColor: row.backgroundColor,
|
||||
backgroundDarkened: row.backgroundDarkened,
|
||||
backgroundMode:
|
||||
row.backgroundMode === "blurred" ||
|
||||
row.backgroundMode === "solid" ||
|
||||
row.backgroundMode === "black"
|
||||
? row.backgroundMode
|
||||
: null,
|
||||
blurPixels: row.blurPixels,
|
||||
foregroundShadow: row.foregroundShadow,
|
||||
},
|
||||
providerId: row.providerId,
|
||||
sourceId: row.sourceId,
|
||||
timelineId: row.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
function slotValues(slot: NewStoredSlot, position: number) {
|
||||
return {
|
||||
backgroundColor: slot.presentation.backgroundColor,
|
||||
backgroundDarkened: slot.presentation.backgroundDarkened,
|
||||
backgroundMode: slot.presentation.backgroundMode,
|
||||
blurPixels: slot.presentation.blurPixels,
|
||||
durationOverrideMs: slot.durationOverrideMs,
|
||||
eligibleFrom: slot.eligibleFrom,
|
||||
eligibleUntil: slot.eligibleUntil,
|
||||
enabled: slot.enabled,
|
||||
foregroundShadow: slot.presentation.foregroundShadow,
|
||||
id: slot.id,
|
||||
itemId: slot.itemId,
|
||||
kind: slot.kind,
|
||||
position,
|
||||
providerId: slot.providerId,
|
||||
sourceId: slot.sourceId,
|
||||
timelineId: slot.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
|
||||
/**
|
||||
* Rewrites `position` for one timeline from an explicit order.
|
||||
*
|
||||
* Positions are written in two passes, through a temporary negative range, because the unique
|
||||
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
|
||||
* another still holds 1 would collide. Negative positions are outside every real value, so the
|
||||
* intermediate state cannot be mistaken for an order.
|
||||
*/
|
||||
async function renumber(
|
||||
transaction: ModuleDatabase,
|
||||
timelineId: string,
|
||||
orderedSlotIds: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||
await transaction
|
||||
.update(timelineSlots)
|
||||
.set({ position: -1 - index })
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
}
|
||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||
await transaction
|
||||
.update(timelineSlots)
|
||||
.set({ position: index, updatedAt: new Date() })
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
}
|
||||
await bumpRevision(transaction, timelineId);
|
||||
}
|
||||
|
||||
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
|
||||
await transaction
|
||||
.update(timelines)
|
||||
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
|
||||
.where(eq(timelines.id, timelineId));
|
||||
}
|
||||
|
||||
async function orderedSlotIdsFor(
|
||||
transaction: ModuleDatabase,
|
||||
timelineId: string,
|
||||
): Promise<string[]> {
|
||||
const rows = await transaction
|
||||
.select({ id: timelineSlots.id, position: timelineSlots.position })
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.timelineId, timelineId))
|
||||
.orderBy(asc(timelineSlots.position));
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function timelineIdForSlot(
|
||||
transaction: ModuleDatabase,
|
||||
slotId: string,
|
||||
): Promise<string | undefined> {
|
||||
const rows = await transaction
|
||||
.select({ timelineId: timelineSlots.timelineId })
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
return rows[0]?.timelineId;
|
||||
}
|
||||
|
||||
return {
|
||||
async addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const existing = await orderedSlotIdsFor(transaction, slot.timelineId);
|
||||
const index =
|
||||
position === undefined
|
||||
? existing.length
|
||||
: Math.max(0, Math.min(position, existing.length));
|
||||
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
|
||||
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
|
||||
await renumber(transaction, slot.timelineId, next);
|
||||
});
|
||||
},
|
||||
|
||||
async addSource(source: NewStoredProviderSource): Promise<void> {
|
||||
await database.insert(providerSources).values({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
selectionScope: source.selectionScope,
|
||||
sourceId: source.sourceId,
|
||||
timelineId: source.timelineId,
|
||||
});
|
||||
},
|
||||
|
||||
async createTimeline(input: {
|
||||
readonly timeline: NewStoredTimeline;
|
||||
readonly sources: readonly NewStoredProviderSource[];
|
||||
readonly slots: readonly NewStoredSlot[];
|
||||
}): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(timelines).values({
|
||||
description: input.timeline.description,
|
||||
eligibleFrom: input.timeline.eligibleFrom,
|
||||
eligibleUntil: input.timeline.eligibleUntil,
|
||||
id: input.timeline.id,
|
||||
name: input.timeline.name,
|
||||
});
|
||||
if (input.sources.length > 0) {
|
||||
await transaction.insert(providerSources).values(
|
||||
input.sources.map((source) => ({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
selectionScope: source.selectionScope,
|
||||
sourceId: source.sourceId,
|
||||
timelineId: source.timelineId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (input.slots.length > 0) {
|
||||
await transaction
|
||||
.insert(timelineSlots)
|
||||
.values(input.slots.map((slot, index) => slotValues(slot, index)));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTimeline(timelineId: string): Promise<void> {
|
||||
// Sources and slots cascade with the timeline; resolved selections do not, because history is
|
||||
// a record of what a screen actually showed.
|
||||
await database.delete(timelines).where(eq(timelines.id, timelineId));
|
||||
},
|
||||
|
||||
async findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
|
||||
const timelineRows = await database
|
||||
.select()
|
||||
.from(timelines)
|
||||
.where(eq(timelines.id, timelineId));
|
||||
const row = timelineRows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
const [sourceRows, slotRows] = await Promise.all([
|
||||
database
|
||||
.select()
|
||||
.from(providerSources)
|
||||
.where(eq(providerSources.timelineId, timelineId))
|
||||
.orderBy(asc(providerSources.createdAt)),
|
||||
database
|
||||
.select()
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.timelineId, timelineId))
|
||||
.orderBy(asc(timelineSlots.position)),
|
||||
]);
|
||||
return {
|
||||
slots: slotRows.map(toSlot),
|
||||
sources: sourceRows.map(toSource),
|
||||
timeline: toTimeline(row),
|
||||
};
|
||||
},
|
||||
|
||||
async listTimelines(): Promise<readonly StoredTimeline[]> {
|
||||
const rows = await database.select().from(timelines).orderBy(asc(timelines.name));
|
||||
return rows.map(toTimeline);
|
||||
},
|
||||
|
||||
async removeSlot(slotId: string): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
await transaction.delete(timelineSlots).where(eq(timelineSlots.id, slotId));
|
||||
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||
});
|
||||
},
|
||||
|
||||
async removeSource(sourceId: string): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.select({ timelineId: providerSources.timelineId })
|
||||
.from(providerSources)
|
||||
.where(eq(providerSources.id, sourceId));
|
||||
const timelineId = rows[0]?.timelineId;
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
// Slots referencing the source cascade with it; the remainder is renumbered so the pattern
|
||||
// an operator reads never has a hole in it.
|
||||
await transaction.delete(providerSources).where(eq(providerSources.id, sourceId));
|
||||
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||
});
|
||||
},
|
||||
|
||||
async reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const current = await orderedSlotIdsFor(transaction, timelineId);
|
||||
const given = new Set(orderedSlotIds);
|
||||
// Exhaustive and exact: anything else could lose a slot, duplicate one, or leave a gap.
|
||||
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
|
||||
throw new SignageStoreConflict("timeline.slotOrder");
|
||||
}
|
||||
for (const slotId of current) {
|
||||
if (!given.has(slotId)) {
|
||||
throw new SignageStoreConflict("timeline.slotOrder");
|
||||
}
|
||||
}
|
||||
await renumber(transaction, timelineId, orderedSlotIds);
|
||||
});
|
||||
},
|
||||
|
||||
async timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
|
||||
const rows = await database
|
||||
.select({
|
||||
activeTimelineId: playbackStates.activeTimelineId,
|
||||
pendingTimelineId: playbackStates.pendingTimelineId,
|
||||
scheduledTimelineId: playbackStates.scheduledTimelineId,
|
||||
})
|
||||
.from(playbackStates)
|
||||
.where(
|
||||
or(
|
||||
eq(playbackStates.activeTimelineId, timelineId),
|
||||
eq(playbackStates.pendingTimelineId, timelineId),
|
||||
eq(playbackStates.scheduledTimelineId, timelineId),
|
||||
),
|
||||
);
|
||||
return {
|
||||
activeGroups: rows.filter((row) => row.activeTimelineId === timelineId).length,
|
||||
pendingGroups: rows.filter((row) => row.pendingTimelineId === timelineId).length,
|
||||
scheduledGroups: rows.filter((row) => row.scheduledTimelineId === timelineId).length,
|
||||
};
|
||||
},
|
||||
|
||||
async updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (changes.enabled !== undefined) {
|
||||
values.enabled = changes.enabled;
|
||||
}
|
||||
if (changes.durationOverrideMs !== undefined) {
|
||||
values.durationOverrideMs = changes.durationOverrideMs;
|
||||
}
|
||||
if (changes.eligibleFrom !== undefined) {
|
||||
values.eligibleFrom = changes.eligibleFrom;
|
||||
}
|
||||
if (changes.eligibleUntil !== undefined) {
|
||||
values.eligibleUntil = changes.eligibleUntil;
|
||||
}
|
||||
if (changes.itemId !== undefined) {
|
||||
values.itemId = changes.itemId;
|
||||
}
|
||||
const presentation = changes.presentation;
|
||||
if (presentation) {
|
||||
if (presentation.backgroundMode !== undefined) {
|
||||
values.backgroundMode = presentation.backgroundMode;
|
||||
}
|
||||
if (presentation.backgroundColor !== undefined) {
|
||||
values.backgroundColor = presentation.backgroundColor;
|
||||
}
|
||||
if (presentation.backgroundDarkened !== undefined) {
|
||||
values.backgroundDarkened = presentation.backgroundDarkened;
|
||||
}
|
||||
if (presentation.blurPixels !== undefined) {
|
||||
values.blurPixels = presentation.blurPixels;
|
||||
}
|
||||
if (presentation.foregroundShadow !== undefined) {
|
||||
values.foregroundShadow = presentation.foregroundShadow;
|
||||
}
|
||||
}
|
||||
await transaction.update(timelineSlots).set(values).where(eq(timelineSlots.id, slotId));
|
||||
await bumpRevision(transaction, timelineId);
|
||||
});
|
||||
},
|
||||
|
||||
async updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
|
||||
const values: Record<string, unknown> = {
|
||||
revision: sql`${timelines.revision} + 1`,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (changes.name !== undefined) {
|
||||
values.name = changes.name;
|
||||
}
|
||||
if (changes.description !== undefined) {
|
||||
values.description = changes.description;
|
||||
}
|
||||
if (changes.eligibleFrom !== undefined) {
|
||||
values.eligibleFrom = changes.eligibleFrom;
|
||||
}
|
||||
if (changes.eligibleUntil !== undefined) {
|
||||
values.eligibleUntil = changes.eligibleUntil;
|
||||
}
|
||||
await database.update(timelines).set(values).where(eq(timelines.id, timelineId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { viewers } from "../../schema/tables.js";
|
||||
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js";
|
||||
|
||||
/** The viewer half of Signage's production store. Deliberately dull: heartbeat coalescing, health
|
||||
* derivation, and every other decision live above it. */
|
||||
|
||||
function toViewer(row: typeof viewers.$inferSelect): StoredViewer {
|
||||
return {
|
||||
cachedAssetCount: row.cachedAssetCount,
|
||||
channelId: row.channelId,
|
||||
createdAt: row.createdAt,
|
||||
deviceId: row.deviceId,
|
||||
id: row.id,
|
||||
lastError:
|
||||
row.lastErrorCode === null || row.lastErrorAt === null
|
||||
? null
|
||||
: { at: row.lastErrorAt, code: row.lastErrorCode, detail: row.lastErrorDetail },
|
||||
lastSeenAt: row.lastSeenAt,
|
||||
name: row.name,
|
||||
offlineReady: row.offlineReady,
|
||||
reportedContributionId: row.reportedContributionId,
|
||||
reportedPlaybackRevision: row.reportedPlaybackRevision,
|
||||
reportedProviderId: row.reportedProviderId,
|
||||
reportedSelectionId: row.reportedSelectionId,
|
||||
reportedTimelineId: row.reportedTimelineId,
|
||||
viewerKey: row.viewerKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function createViewerStore(database: ModuleDatabase): SignageViewerStore {
|
||||
return {
|
||||
async deleteViewer(viewerId: string): Promise<void> {
|
||||
await database.delete(viewers).where(eq(viewers.id, viewerId));
|
||||
},
|
||||
|
||||
async findViewer(viewerId: string): Promise<StoredViewer | undefined> {
|
||||
const rows = await database.select().from(viewers).where(eq(viewers.id, viewerId));
|
||||
const row = rows[0];
|
||||
return row ? toViewer(row) : undefined;
|
||||
},
|
||||
|
||||
async findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
|
||||
const rows = await database.select().from(viewers).where(eq(viewers.viewerKey, viewerKey));
|
||||
const row = rows[0];
|
||||
return row ? toViewer(row) : undefined;
|
||||
},
|
||||
|
||||
async insertViewer(viewer: NewStoredViewer): Promise<void> {
|
||||
await database.insert(viewers).values({
|
||||
cachedAssetCount: viewer.cachedAssetCount,
|
||||
channelId: viewer.channelId,
|
||||
deviceId: viewer.deviceId,
|
||||
id: viewer.id,
|
||||
name: viewer.name,
|
||||
offlineReady: viewer.offlineReady,
|
||||
reportedContributionId: viewer.reportedContributionId,
|
||||
reportedPlaybackRevision: viewer.reportedPlaybackRevision,
|
||||
reportedProviderId: viewer.reportedProviderId,
|
||||
reportedSelectionId: viewer.reportedSelectionId,
|
||||
reportedTimelineId: viewer.reportedTimelineId,
|
||||
viewerKey: viewer.viewerKey,
|
||||
});
|
||||
},
|
||||
|
||||
async listViewers(): Promise<readonly StoredViewer[]> {
|
||||
const rows = await database.select().from(viewers).orderBy(asc(viewers.name));
|
||||
return rows.map(toViewer);
|
||||
},
|
||||
|
||||
async updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
for (const key of [
|
||||
"cachedAssetCount",
|
||||
"channelId",
|
||||
"deviceId",
|
||||
"lastSeenAt",
|
||||
"name",
|
||||
"offlineReady",
|
||||
"reportedContributionId",
|
||||
"reportedPlaybackRevision",
|
||||
"reportedProviderId",
|
||||
"reportedSelectionId",
|
||||
"reportedTimelineId",
|
||||
] as const) {
|
||||
if (changes[key] !== undefined) {
|
||||
values[key] = changes[key];
|
||||
}
|
||||
}
|
||||
if (changes.lastError !== undefined) {
|
||||
values.lastErrorAt = changes.lastError?.at ?? null;
|
||||
values.lastErrorCode = changes.lastError?.code ?? null;
|
||||
values.lastErrorDetail = changes.lastError?.detail ?? null;
|
||||
}
|
||||
await database.update(viewers).set(values).where(eq(viewers.id, viewerId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
imageAssets,
|
||||
imageRenditions,
|
||||
playbackStates,
|
||||
timelineSlots,
|
||||
} from "../../schema/tables.js";
|
||||
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../shared/native-images.js";
|
||||
import { createPlaybackStore } from "./drizzle-playback.js";
|
||||
import { createTimelineStore } from "./drizzle-timelines.js";
|
||||
import { createViewerStore } from "./drizzle-viewers.js";
|
||||
import {
|
||||
type NewStoredImage,
|
||||
type SignageImageStore,
|
||||
type SignageStore,
|
||||
SignageStoreConflict,
|
||||
type StoredImage,
|
||||
type StoredImageReferences,
|
||||
type StoredImageRendition,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* The production implementation of Signage's data port, over the platform's `ModuleDatabase`.
|
||||
*
|
||||
* This file is deliberately dull: it maps rows and nothing else. Every decision — what to write,
|
||||
* when a revision changes, what a no-op is — lives in the services above it, so that behaviour can
|
||||
* be proven without PostgreSQL and this mapping can be proven against a live one.
|
||||
*
|
||||
* The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a
|
||||
* connection string, or touches another module's table.
|
||||
*/
|
||||
|
||||
const assetColumns = {
|
||||
contentSha256: imageAssets.contentSha256,
|
||||
createdAt: imageAssets.createdAt,
|
||||
displayName: imageAssets.displayName,
|
||||
encodedHeight: imageAssets.encodedHeight,
|
||||
encodedWidth: imageAssets.encodedWidth,
|
||||
expiresAt: imageAssets.expiresAt,
|
||||
hasAlpha: imageAssets.hasAlpha,
|
||||
height: imageAssets.height,
|
||||
id: imageAssets.id,
|
||||
orientation: imageAssets.orientation,
|
||||
originalByteSize: imageAssets.originalByteSize,
|
||||
originalContentType: imageAssets.originalContentType,
|
||||
originalFilename: imageAssets.originalFilename,
|
||||
originalObjectKey: imageAssets.originalObjectKey,
|
||||
status: imageAssets.status,
|
||||
width: imageAssets.width,
|
||||
};
|
||||
|
||||
type AssetRow = {
|
||||
[Key in keyof typeof assetColumns]: (typeof imageAssets.$inferSelect)[Key];
|
||||
};
|
||||
|
||||
const renditionColumns = {
|
||||
assetId: imageRenditions.assetId,
|
||||
byteSize: imageRenditions.byteSize,
|
||||
contentSha256: imageRenditions.contentSha256,
|
||||
contentType: imageRenditions.contentType,
|
||||
height: imageRenditions.height,
|
||||
kind: imageRenditions.kind,
|
||||
objectKey: imageRenditions.objectKey,
|
||||
width: imageRenditions.width,
|
||||
};
|
||||
|
||||
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the
|
||||
* driver's message, detail, and constraint text are never surfaced. */
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
|
||||
);
|
||||
}
|
||||
|
||||
function toRendition(row: {
|
||||
byteSize: number;
|
||||
contentSha256: string;
|
||||
contentType: string;
|
||||
height: number;
|
||||
kind: string;
|
||||
objectKey: string;
|
||||
width: number;
|
||||
}): StoredImageRendition {
|
||||
return {
|
||||
byteSize: row.byteSize,
|
||||
contentSha256: row.contentSha256,
|
||||
contentType: row.contentType,
|
||||
height: row.height,
|
||||
kind: row.kind === "thumbnail" ? "thumbnail" : "display",
|
||||
objectKey: row.objectKey,
|
||||
width: row.width,
|
||||
};
|
||||
}
|
||||
|
||||
function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): StoredImage {
|
||||
return {
|
||||
contentSha256: row.contentSha256,
|
||||
createdAt: row.createdAt,
|
||||
displayName: row.displayName,
|
||||
encodedHeight: row.encodedHeight,
|
||||
encodedWidth: row.encodedWidth,
|
||||
expiresAt: row.expiresAt,
|
||||
hasAlpha: row.hasAlpha,
|
||||
height: row.height,
|
||||
id: row.id,
|
||||
orientation: row.orientation,
|
||||
originalByteSize: row.originalByteSize,
|
||||
originalContentType: row.originalContentType,
|
||||
originalFilename: row.originalFilename,
|
||||
originalObjectKey: row.originalObjectKey,
|
||||
renditions,
|
||||
status: row.status === "ready" ? "ready" : row.status === "failed" ? "failed" : "pending",
|
||||
width: row.width,
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageStore(database: ModuleDatabase): SignageImageStore {
|
||||
async function renditionsFor(
|
||||
assetIds: readonly string[],
|
||||
): Promise<Map<string, StoredImageRendition[]>> {
|
||||
const grouped = new Map<string, StoredImageRendition[]>();
|
||||
if (assetIds.length === 0) {
|
||||
return grouped;
|
||||
}
|
||||
const rows = await database
|
||||
.select(renditionColumns)
|
||||
.from(imageRenditions)
|
||||
.where(inArray(imageRenditions.assetId, [...assetIds]));
|
||||
for (const row of rows) {
|
||||
const list = grouped.get(row.assetId) ?? [];
|
||||
list.push(toRendition(row));
|
||||
grouped.set(row.assetId, list);
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((left, right) => left.kind.localeCompare(right.kind));
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function hydrate(rows: readonly AssetRow[]): Promise<StoredImage[]> {
|
||||
const renditions = await renditionsFor(rows.map((row) => row.id));
|
||||
return rows.map((row) => toImage(row, renditions.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
return {
|
||||
async countImageReferences(imageId: string): Promise<StoredImageReferences> {
|
||||
const [slots, holds] = await Promise.all([
|
||||
database
|
||||
.select({ id: timelineSlots.id })
|
||||
.from(timelineSlots)
|
||||
.where(
|
||||
and(
|
||||
eq(timelineSlots.providerId, SIGNAGE_NATIVE_PROVIDER_ID),
|
||||
eq(timelineSlots.itemId, imageId),
|
||||
),
|
||||
),
|
||||
database
|
||||
.select({ groupId: playbackStates.groupId })
|
||||
.from(playbackStates)
|
||||
.where(eq(playbackStates.staticAssetId, imageId)),
|
||||
]);
|
||||
return { slots: slots.length, staticHolds: holds.length };
|
||||
},
|
||||
|
||||
async deleteImage(imageId: string): Promise<void> {
|
||||
// Renditions cascade with the asset, so one delete is the whole removal.
|
||||
await database.delete(imageAssets).where(eq(imageAssets.id, imageId));
|
||||
},
|
||||
|
||||
async findImage(imageId: string): Promise<StoredImage | undefined> {
|
||||
const rows = (await database
|
||||
.select(assetColumns)
|
||||
.from(imageAssets)
|
||||
.where(eq(imageAssets.id, imageId))) as AssetRow[];
|
||||
return (await hydrate(rows))[0];
|
||||
},
|
||||
|
||||
async findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
|
||||
const rows = (await database
|
||||
.select(assetColumns)
|
||||
.from(imageAssets)
|
||||
.where(
|
||||
and(eq(imageAssets.contentSha256, contentSha256), eq(imageAssets.status, "ready")),
|
||||
)) as AssetRow[];
|
||||
return (await hydrate(rows))[0];
|
||||
},
|
||||
|
||||
async insertImage(image: NewStoredImage): Promise<void> {
|
||||
try {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(imageAssets).values({
|
||||
contentSha256: image.contentSha256,
|
||||
displayName: image.displayName,
|
||||
encodedHeight: image.encodedHeight,
|
||||
encodedWidth: image.encodedWidth,
|
||||
expiresAt: image.expiresAt,
|
||||
hasAlpha: image.hasAlpha,
|
||||
height: image.height,
|
||||
id: image.id,
|
||||
orientation: image.orientation,
|
||||
originalByteSize: image.originalByteSize,
|
||||
originalContentType: image.originalContentType,
|
||||
originalFilename: image.originalFilename,
|
||||
originalObjectKey: image.originalObjectKey,
|
||||
status: image.status,
|
||||
width: image.width,
|
||||
});
|
||||
await transaction.insert(imageRenditions).values(
|
||||
image.renditions.map((rendition) => ({
|
||||
assetId: image.id,
|
||||
byteSize: rendition.byteSize,
|
||||
contentSha256: rendition.contentSha256,
|
||||
contentType: rendition.contentType,
|
||||
height: rendition.height,
|
||||
id: randomUUID(),
|
||||
kind: rendition.kind,
|
||||
objectKey: rendition.objectKey,
|
||||
width: rendition.width,
|
||||
})),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new SignageStoreConflict("image.contentSha256");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async listImages(): Promise<readonly StoredImage[]> {
|
||||
const rows = (await database
|
||||
.select(assetColumns)
|
||||
.from(imageAssets)
|
||||
.orderBy(desc(imageAssets.createdAt))) as AssetRow[];
|
||||
return hydrate(rows);
|
||||
},
|
||||
|
||||
async updateImage(
|
||||
imageId: string,
|
||||
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||
): Promise<void> {
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (changes.displayName !== undefined) {
|
||||
values.displayName = changes.displayName;
|
||||
}
|
||||
if (changes.expiresAt !== undefined) {
|
||||
values.expiresAt = changes.expiresAt;
|
||||
}
|
||||
await database.update(imageAssets).set(values).where(eq(imageAssets.id, imageId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signage's complete production store: the image half above, plus the timeline half beside it.
|
||||
*
|
||||
* Composed rather than written as one object because the two halves share nothing but the database
|
||||
* handle, and keeping them separate is what lets each be read on its own.
|
||||
*/
|
||||
export function createSignageStore(database: ModuleDatabase): SignageStore {
|
||||
return {
|
||||
...createImageStore(database),
|
||||
...createTimelineStore(database),
|
||||
...createPlaybackStore(database),
|
||||
...createViewerStore(database),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Signage's narrow data port.
|
||||
*
|
||||
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative
|
||||
* playback, viewer health — is written against this interface rather than against Drizzle directly.
|
||||
* There are two implementations: the production one over the platform's `ModuleDatabase`
|
||||
* (`./drizzle.ts`), and an in-memory one used by tests.
|
||||
*
|
||||
* This is the same discipline Core's own migration runner follows, and it exists for the same
|
||||
* reason: the behaviour worth proving is *what Signage decides*, and proving that against a real
|
||||
* PostgreSQL for every case would make the interesting tests slow, flaky, and rare. The Drizzle
|
||||
* implementation is exercised separately, against a live disposable database, where what is being
|
||||
* proven is the mapping rather than the decision.
|
||||
*
|
||||
* The port is deliberately **narrow and domain-shaped**. It exposes the operations Signage performs,
|
||||
* not a query language: there is no way to express an arbitrary join, and no way for a caller to
|
||||
* reach a table this file does not name.
|
||||
*/
|
||||
|
||||
export interface StoredImageRendition {
|
||||
readonly kind: "display" | "thumbnail";
|
||||
readonly objectKey: string;
|
||||
readonly contentType: string;
|
||||
readonly contentSha256: string;
|
||||
readonly byteSize: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface StoredImage {
|
||||
readonly id: string;
|
||||
readonly contentSha256: string;
|
||||
readonly displayName: string;
|
||||
readonly originalFilename: string;
|
||||
readonly originalObjectKey: string;
|
||||
readonly originalContentType: string;
|
||||
readonly originalByteSize: number;
|
||||
/** How the original should appear once its own orientation is applied. */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly encodedWidth: number;
|
||||
readonly encodedHeight: number;
|
||||
readonly orientation: number | null;
|
||||
readonly hasAlpha: boolean;
|
||||
readonly status: "pending" | "ready" | "failed";
|
||||
/** Null is permanent. */
|
||||
readonly expiresAt: Date | null;
|
||||
readonly createdAt: Date;
|
||||
readonly renditions: readonly StoredImageRendition[];
|
||||
}
|
||||
|
||||
export interface NewStoredImage extends Omit<StoredImage, "createdAt" | "status"> {
|
||||
readonly status: "ready";
|
||||
}
|
||||
|
||||
export interface StoredImageReferences {
|
||||
/** Timeline slots pinned to this image. */
|
||||
readonly slots: number;
|
||||
/** Channel groups currently holding this image as a static hold. */
|
||||
readonly staticHolds: number;
|
||||
}
|
||||
|
||||
export interface SignageImageStore {
|
||||
listImages(): Promise<readonly StoredImage[]>;
|
||||
findImage(imageId: string): Promise<StoredImage | undefined>;
|
||||
/** Content-addressed lookup, restricted to `ready` assets: an asset whose ingest failed must not
|
||||
* make a retry of the same bytes a no-op. */
|
||||
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined>;
|
||||
/**
|
||||
* Inserts the asset and every rendition **atomically**. A partially inserted asset would be
|
||||
* selectable by playback with renditions that do not exist.
|
||||
*
|
||||
* Rejects with {@link SignageStoreConflict} when an asset with the same content hash already
|
||||
* exists, so a race between two identical uploads resolves to one asset rather than to a
|
||||
* corrupted pair.
|
||||
*/
|
||||
insertImage(image: NewStoredImage): Promise<void>;
|
||||
/** Applies only the fields present. An exact no-op is the caller's to detect, not the store's. */
|
||||
updateImage(
|
||||
imageId: string,
|
||||
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||
): Promise<void>;
|
||||
deleteImage(imageId: string): Promise<void>;
|
||||
countImageReferences(imageId: string): Promise<StoredImageReferences>;
|
||||
}
|
||||
|
||||
/** Thrown by a store implementation when a uniqueness guarantee the database owns was violated.
|
||||
* Carries no driver message, no SQL, and no constraint name — only which guarantee it was. */
|
||||
export class SignageStoreConflict extends Error {
|
||||
readonly guarantee: string;
|
||||
|
||||
constructor(guarantee: string) {
|
||||
super("A Signage uniqueness guarantee was violated.");
|
||||
this.name = "SignageStoreConflict";
|
||||
this.guarantee = guarantee;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Timelines, provider sources, and slots.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
export interface StoredTimeline {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
/** Half-open `[eligibleFrom, eligibleUntil)`. */
|
||||
readonly eligibleFrom: Date | null;
|
||||
readonly eligibleUntil: Date | null;
|
||||
/** Increments only on a semantic change, so a client can compare and refetch. */
|
||||
readonly revision: number;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface StoredProviderSource {
|
||||
readonly id: string;
|
||||
readonly timelineId: string;
|
||||
/** Another module's opaque identity. Never a foreign key. */
|
||||
readonly providerId: string;
|
||||
readonly sourceId: string;
|
||||
readonly label: string;
|
||||
/** Stable and opaque. Every slot referencing this row passes it to `resolveNext`, which is what
|
||||
* makes repeated slots one rotation rather than several (ADR 0003). */
|
||||
readonly selectionScope: string;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export type StoredSlotKind = "provider-source" | "pinned-item";
|
||||
|
||||
/** Native-image presentation overrides. Every field is `null` for "take the installation default",
|
||||
* and the whole group is meaningless for a slot whose content comes from another module — an
|
||||
* external provider owns its rendering completely. */
|
||||
export interface StoredSlotPresentation {
|
||||
readonly backgroundMode: "blurred" | "solid" | "black" | null;
|
||||
readonly backgroundColor: string | null;
|
||||
readonly backgroundDarkened: boolean | null;
|
||||
readonly blurPixels: number | null;
|
||||
readonly foregroundShadow: boolean | null;
|
||||
}
|
||||
|
||||
export interface StoredSlot {
|
||||
readonly id: string;
|
||||
readonly timelineId: string;
|
||||
/** Unique within the timeline and contiguous from zero. */
|
||||
readonly position: number;
|
||||
readonly kind: StoredSlotKind;
|
||||
/** Set for a `provider-source` slot. */
|
||||
readonly sourceId: string | null;
|
||||
/** Set for a `pinned-item` slot. Both opaque. */
|
||||
readonly providerId: string | null;
|
||||
readonly itemId: string | null;
|
||||
readonly enabled: boolean;
|
||||
readonly durationOverrideMs: number | null;
|
||||
readonly eligibleFrom: Date | null;
|
||||
readonly eligibleUntil: Date | null;
|
||||
readonly presentation: StoredSlotPresentation;
|
||||
}
|
||||
|
||||
/** A timeline and everything in it, in one read. Slots are ordered by position. */
|
||||
export interface StoredTimelineDetail {
|
||||
readonly timeline: StoredTimeline;
|
||||
readonly sources: readonly StoredProviderSource[];
|
||||
readonly slots: readonly StoredSlot[];
|
||||
}
|
||||
|
||||
export type NewStoredTimeline = Omit<StoredTimeline, "createdAt" | "revision" | "updatedAt">;
|
||||
export type NewStoredProviderSource = Omit<StoredProviderSource, "createdAt">;
|
||||
/** Position is assigned by the store, which is what keeps positions contiguous without every caller
|
||||
* having to know the current length. */
|
||||
export type NewStoredSlot = Omit<StoredSlot, "position">;
|
||||
|
||||
export interface TimelineChanges {
|
||||
readonly name?: string;
|
||||
readonly description?: string | null;
|
||||
readonly eligibleFrom?: Date | null;
|
||||
readonly eligibleUntil?: Date | null;
|
||||
}
|
||||
|
||||
export interface SlotChanges {
|
||||
readonly enabled?: boolean;
|
||||
readonly durationOverrideMs?: number | null;
|
||||
readonly eligibleFrom?: Date | null;
|
||||
readonly eligibleUntil?: Date | null;
|
||||
readonly presentation?: Partial<StoredSlotPresentation>;
|
||||
readonly itemId?: string;
|
||||
}
|
||||
|
||||
/** Where a timeline is currently referenced by live playback. Reported so a delete can refuse with
|
||||
* something an operator can act on rather than cascading silently. */
|
||||
export interface StoredTimelineUsage {
|
||||
readonly activeGroups: number;
|
||||
readonly pendingGroups: number;
|
||||
readonly scheduledGroups: number;
|
||||
}
|
||||
|
||||
export interface SignageTimelineStore {
|
||||
listTimelines(): Promise<readonly StoredTimeline[]>;
|
||||
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined>;
|
||||
/**
|
||||
* Creates a timeline together with every source and slot it starts with, **atomically**.
|
||||
*
|
||||
* One operation rather than three, because that is what makes duplication safe: a copy that
|
||||
* committed its timeline and then failed on its slots would be a half-copy an operator has to
|
||||
* find and clean up. Slot positions are assigned from the order given.
|
||||
*/
|
||||
createTimeline(input: {
|
||||
readonly timeline: NewStoredTimeline;
|
||||
readonly sources: readonly NewStoredProviderSource[];
|
||||
readonly slots: readonly NewStoredSlot[];
|
||||
}): Promise<void>;
|
||||
/** Applies only the fields present, and bumps the revision. An exact no-op is the caller's to
|
||||
* detect: the store does not guess what "changed" means. */
|
||||
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void>;
|
||||
deleteTimeline(timelineId: string): Promise<void>;
|
||||
timelineUsage(timelineId: string): Promise<StoredTimelineUsage>;
|
||||
|
||||
addSource(source: NewStoredProviderSource): Promise<void>;
|
||||
/** Removes the source and every slot that referenced it, then renumbers what remains. */
|
||||
removeSource(sourceId: string): Promise<void>;
|
||||
|
||||
/** Appends a slot at the end, or inserts it at `position` and shifts the rest. */
|
||||
addSlot(slot: NewStoredSlot, position?: number): Promise<void>;
|
||||
updateSlot(slotId: string, changes: SlotChanges): Promise<void>;
|
||||
/** Removes the slot and renumbers the remainder so positions stay contiguous. */
|
||||
removeSlot(slotId: string): Promise<void>;
|
||||
/**
|
||||
* Rewrites the whole order.
|
||||
*
|
||||
* All-or-nothing and exhaustive: the given ids must be exactly the timeline's current slots, so a
|
||||
* reorder can never lose a slot, duplicate one, or leave a gap. Rejects with
|
||||
* {@link SignageStoreConflict} otherwise.
|
||||
*/
|
||||
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void>;
|
||||
}
|
||||
|
||||
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Channel groups, channels, playback, and resolved selections.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
export interface StoredChannel {
|
||||
readonly id: string;
|
||||
readonly groupId: string;
|
||||
/** The audience key handed to a provider. The base channel's is `default`. */
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface StoredChannelGroup {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
|
||||
export type StoredTransport = "idle" | "playing" | "paused" | "static";
|
||||
|
||||
/** One channel group's authoritative clock. Every instant is absolute (ADR 0005). */
|
||||
export interface StoredPlaybackState {
|
||||
readonly groupId: string;
|
||||
readonly activeTimelineId: string | null;
|
||||
readonly pendingTimelineId: string | null;
|
||||
readonly pendingRequestedAt: Date | null;
|
||||
readonly scheduledTimelineId: string | null;
|
||||
readonly scheduledActivationAt: Date | null;
|
||||
readonly currentSelectionId: string | null;
|
||||
readonly currentSlotId: string | null;
|
||||
readonly slotStartedAt: Date | null;
|
||||
readonly slotDeadlineAt: Date | null;
|
||||
readonly pausedRemainingMs: number | null;
|
||||
readonly paused: boolean;
|
||||
readonly blackout: boolean;
|
||||
readonly staticAssetId: string | null;
|
||||
readonly revision: number;
|
||||
readonly nextSequence: number;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface StoredChannelGroupDetail {
|
||||
readonly group: StoredChannelGroup;
|
||||
readonly channels: readonly StoredChannel[];
|
||||
readonly playback: StoredPlaybackState;
|
||||
}
|
||||
|
||||
export type PlaybackChanges = Partial<
|
||||
Omit<StoredPlaybackState, "groupId" | "revision" | "updatedAt">
|
||||
>;
|
||||
|
||||
export interface StoredSelectionVariant {
|
||||
readonly audienceKey: string;
|
||||
readonly isDefault: boolean;
|
||||
readonly payload: unknown;
|
||||
}
|
||||
|
||||
/** One logical selection, resolved once for a whole channel group. */
|
||||
export interface StoredSelection {
|
||||
readonly id: string;
|
||||
readonly groupId: string;
|
||||
readonly sequence: number;
|
||||
readonly timelineId: string | null;
|
||||
readonly slotId: string | null;
|
||||
readonly selectionScope: string | null;
|
||||
readonly providerId: string;
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
readonly rendererContributionId: string | null;
|
||||
readonly rendererContributionVersion: string | null;
|
||||
readonly presentation: unknown;
|
||||
readonly durationMs: number;
|
||||
readonly resolvedAt: Date;
|
||||
readonly startedAt: Date | null;
|
||||
readonly deadlineAt: Date | null;
|
||||
readonly availableUntil: Date | null;
|
||||
readonly withdrawn: boolean;
|
||||
readonly stale: boolean;
|
||||
/** Increments when a refresh changes payloads without changing which item this is — the change
|
||||
* that updates a mounted renderer in place rather than restarting its slot. */
|
||||
readonly revision: number;
|
||||
readonly variants: readonly StoredSelectionVariant[];
|
||||
}
|
||||
|
||||
export type NewStoredSelection = Omit<StoredSelection, "resolvedAt" | "revision">;
|
||||
|
||||
export interface SelectionChanges {
|
||||
readonly startedAt?: Date | null;
|
||||
readonly deadlineAt?: Date | null;
|
||||
readonly availableUntil?: Date | null;
|
||||
readonly withdrawn?: boolean;
|
||||
readonly stale?: boolean;
|
||||
readonly durationMs?: number;
|
||||
readonly itemRevision?: string;
|
||||
readonly presentation?: unknown;
|
||||
/** Replaces the whole variant set. A partial update would leave an audience rendering a payload
|
||||
* from a revision that no longer exists. */
|
||||
readonly variants?: readonly StoredSelectionVariant[];
|
||||
/** Increments the selection's revision, which is what tells a mounted renderer to update. */
|
||||
readonly bumpRevision?: boolean;
|
||||
}
|
||||
|
||||
export interface SignagePlaybackStore {
|
||||
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]>;
|
||||
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined>;
|
||||
/** Creates a group, its playback row, and its one default channel, atomically. A group without a
|
||||
* default channel would have nowhere for a viewer to land. */
|
||||
createChannelGroup(input: {
|
||||
readonly groupId: string;
|
||||
readonly name: string;
|
||||
readonly channelId: string;
|
||||
readonly channelName: string;
|
||||
}): Promise<void>;
|
||||
deleteChannelGroup(groupId: string): Promise<void>;
|
||||
addChannel(channel: StoredChannel): Promise<void>;
|
||||
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
||||
removeChannel(channelId: string): Promise<void>;
|
||||
/** Applies only the fields present, and bumps the group's playback revision. */
|
||||
updatePlayback(
|
||||
groupId: string,
|
||||
changes: PlaybackChanges,
|
||||
options?: { readonly bumpRevision?: boolean },
|
||||
): Promise<void>;
|
||||
/** Takes the next sequence number for this group and increments it, atomically. */
|
||||
takeSelectionSequence(groupId: string): Promise<number>;
|
||||
|
||||
insertSelection(selection: NewStoredSelection): Promise<void>;
|
||||
findSelection(selectionId: string): Promise<StoredSelection | undefined>;
|
||||
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void>;
|
||||
/** Most recent first, bounded. This is the history `previous` walks. */
|
||||
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]>;
|
||||
/** The most recent selection for one selection scope, so a provider's rotation can be re-seeded
|
||||
* from what actually played rather than from the beginning. */
|
||||
findLatestSelectionForScope(
|
||||
groupId: string,
|
||||
selectionScope: string,
|
||||
): Promise<StoredSelection | undefined>;
|
||||
/** Keeps the newest `limit` selections for a group and removes the rest. */
|
||||
pruneSelections(groupId: string, limit: number): Promise<void>;
|
||||
}
|
||||
|
||||
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Viewers.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** A sanitized report from a display. Never an exception, a stack, a URL, or a payload. */
|
||||
export interface StoredViewerError {
|
||||
readonly code: string;
|
||||
readonly detail: string | null;
|
||||
readonly at: Date;
|
||||
}
|
||||
|
||||
export interface StoredViewer {
|
||||
readonly id: string;
|
||||
/** The persistent identity a display keeps in its own IndexedDB, so a reconnect is recognized as
|
||||
* the same screen rather than a new one. */
|
||||
readonly viewerKey: string;
|
||||
readonly name: string;
|
||||
/** The **safe** Core device identifier a paired display's principal carries — an opaque
|
||||
* correlation value. Signage never sees, stores, or handles a device credential. */
|
||||
readonly deviceId: string | null;
|
||||
readonly channelId: string | null;
|
||||
readonly lastSeenAt: Date | null;
|
||||
readonly reportedTimelineId: string | null;
|
||||
readonly reportedSelectionId: string | null;
|
||||
readonly reportedProviderId: string | null;
|
||||
readonly reportedContributionId: string | null;
|
||||
readonly reportedPlaybackRevision: number | null;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly lastError: StoredViewerError | null;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export type NewStoredViewer = Omit<StoredViewer, "createdAt" | "lastSeenAt" | "lastError">;
|
||||
|
||||
export interface ViewerChanges {
|
||||
readonly name?: string;
|
||||
readonly channelId?: string | null;
|
||||
readonly deviceId?: string | null;
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly reportedTimelineId?: string | null;
|
||||
readonly reportedSelectionId?: string | null;
|
||||
readonly reportedProviderId?: string | null;
|
||||
readonly reportedContributionId?: string | null;
|
||||
readonly reportedPlaybackRevision?: number | null;
|
||||
readonly offlineReady?: boolean;
|
||||
readonly cachedAssetCount?: number;
|
||||
readonly lastError?: StoredViewerError | null;
|
||||
}
|
||||
|
||||
export interface SignageViewerStore {
|
||||
listViewers(): Promise<readonly StoredViewer[]>;
|
||||
findViewer(viewerId: string): Promise<StoredViewer | undefined>;
|
||||
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined>;
|
||||
insertViewer(viewer: NewStoredViewer): Promise<void>;
|
||||
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void>;
|
||||
deleteViewer(viewerId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||
export type SignageStore = SignageImageStore &
|
||||
SignagePlaybackStore &
|
||||
SignageTimelineStore &
|
||||
SignageViewerStore;
|
||||
@@ -0,0 +1,429 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { SIGNAGE_HEX_COLOR } from "../../shared/settings.js";
|
||||
import { SignageError } from "../errors.js";
|
||||
import {
|
||||
type NewStoredProviderSource,
|
||||
type NewStoredSlot,
|
||||
type SignageStore,
|
||||
SignageStoreConflict,
|
||||
type SlotChanges,
|
||||
type StoredSlotPresentation,
|
||||
type StoredTimelineDetail,
|
||||
type TimelineChanges,
|
||||
} from "../store/types.js";
|
||||
|
||||
/**
|
||||
* Timeline, source, and slot management.
|
||||
*
|
||||
* The rules that matter are here rather than in the routes, because they are decisions rather than
|
||||
* transport: what a duplicate copies and what it deliberately does not, when an edit is a no-op,
|
||||
* and what a delete refuses.
|
||||
*/
|
||||
|
||||
/** Bounds an operator can actually hit. Generous for a real church, small enough that a timeline
|
||||
* stays something a person can read. */
|
||||
export const SIGNAGE_TIMELINE_LIMITS = Object.freeze({
|
||||
maxNameLength: 120,
|
||||
maxSlotDurationMs: 3_600_000,
|
||||
maxSlotsPerTimeline: 200,
|
||||
maxSourcesPerTimeline: 50,
|
||||
minSlotDurationMs: 1000,
|
||||
});
|
||||
|
||||
export interface TimelineIdentitySource {
|
||||
/** Overridable so a test can pin identity. Production takes fresh UUIDs. */
|
||||
readonly newId?: () => string;
|
||||
}
|
||||
|
||||
function nextId(input: TimelineIdentitySource): string {
|
||||
return (input.newId ?? randomUUID)();
|
||||
}
|
||||
|
||||
function requireInterval(from: Date | null | undefined, until: Date | null | undefined): void {
|
||||
if (from && until && from.getTime() >= until.getTime()) {
|
||||
// Half-open `[from, until)`: an empty or inverted window is a mistake an operator wants to hear
|
||||
// about now rather than discover as a slot that never appears.
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "eligibility" });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePresentation(
|
||||
input: Partial<StoredSlotPresentation> | undefined,
|
||||
): StoredSlotPresentation {
|
||||
const presentation: StoredSlotPresentation = {
|
||||
backgroundColor: input?.backgroundColor ?? null,
|
||||
backgroundDarkened: input?.backgroundDarkened ?? null,
|
||||
backgroundMode: input?.backgroundMode ?? null,
|
||||
blurPixels: input?.blurPixels ?? null,
|
||||
foregroundShadow: input?.foregroundShadow ?? null,
|
||||
};
|
||||
if (
|
||||
presentation.backgroundColor !== null &&
|
||||
!SIGNAGE_HEX_COLOR.test(presentation.backgroundColor)
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundColor" });
|
||||
}
|
||||
if (
|
||||
presentation.blurPixels !== null &&
|
||||
(!Number.isSafeInteger(presentation.blurPixels) ||
|
||||
presentation.blurPixels < 0 ||
|
||||
presentation.blurPixels > 120)
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "blurPixels" });
|
||||
}
|
||||
return presentation;
|
||||
}
|
||||
|
||||
export interface CreateTimelineInput extends TimelineIdentitySource {
|
||||
readonly store: SignageStore;
|
||||
readonly name: string;
|
||||
readonly description?: string | null;
|
||||
readonly eligibleFrom?: Date | null;
|
||||
readonly eligibleUntil?: Date | null;
|
||||
}
|
||||
|
||||
export async function createTimeline(input: CreateTimelineInput): Promise<StoredTimelineDetail> {
|
||||
requireInterval(input.eligibleFrom, input.eligibleUntil);
|
||||
const id = nextId(input);
|
||||
await input.store.createTimeline({
|
||||
slots: [],
|
||||
sources: [],
|
||||
timeline: {
|
||||
description: input.description ?? null,
|
||||
eligibleFrom: input.eligibleFrom ?? null,
|
||||
eligibleUntil: input.eligibleUntil ?? null,
|
||||
id,
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
return requireTimeline(input.store, id);
|
||||
}
|
||||
|
||||
export async function requireTimeline(
|
||||
store: SignageStore,
|
||||
timelineId: string,
|
||||
): Promise<StoredTimelineDetail> {
|
||||
const detail = await store.findTimeline(timelineId);
|
||||
if (!detail) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a timeline, and gives every copied source a **new selection scope**.
|
||||
*
|
||||
* That is the one non-obvious decision here, and it is deliberate. A duplicate is a separate
|
||||
* program: an operator copying "Sunday Morning" to make "Sunday Evening" expects the two to rotate
|
||||
* independently, and a shared scope would make the evening loop continue wherever the morning one
|
||||
* happened to be. Slot ids are new for the same reason — history belongs to the timeline that
|
||||
* actually played.
|
||||
*/
|
||||
export async function duplicateTimeline(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly name: string;
|
||||
readonly newId?: () => string;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const original = await requireTimeline(input.store, input.timelineId);
|
||||
const newId = input.newId ?? randomUUID;
|
||||
const timelineId = newId();
|
||||
const sourceIds = new Map<string, string>();
|
||||
|
||||
const sources: NewStoredProviderSource[] = original.sources.map((source) => {
|
||||
const id = newId();
|
||||
sourceIds.set(source.id, id);
|
||||
return {
|
||||
id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
// A new rotation, not a continuation of the original's.
|
||||
selectionScope: newId(),
|
||||
sourceId: source.sourceId,
|
||||
timelineId,
|
||||
};
|
||||
});
|
||||
|
||||
const slots: NewStoredSlot[] = original.slots.map((slot) => ({
|
||||
durationOverrideMs: slot.durationOverrideMs,
|
||||
eligibleFrom: slot.eligibleFrom,
|
||||
eligibleUntil: slot.eligibleUntil,
|
||||
enabled: slot.enabled,
|
||||
id: newId(),
|
||||
itemId: slot.itemId,
|
||||
kind: slot.kind,
|
||||
presentation: slot.presentation,
|
||||
providerId: slot.providerId,
|
||||
sourceId: slot.sourceId === null ? null : (sourceIds.get(slot.sourceId) ?? null),
|
||||
timelineId,
|
||||
}));
|
||||
|
||||
await input.store.createTimeline({
|
||||
slots,
|
||||
sources,
|
||||
timeline: {
|
||||
description: original.timeline.description,
|
||||
eligibleFrom: original.timeline.eligibleFrom,
|
||||
eligibleUntil: original.timeline.eligibleUntil,
|
||||
id: timelineId,
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
return requireTimeline(input.store, timelineId);
|
||||
}
|
||||
|
||||
export async function updateTimeline(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly changes: TimelineChanges;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const current = await requireTimeline(input.store, input.timelineId);
|
||||
const changes: TimelineChanges = {};
|
||||
const timeline = current.timeline;
|
||||
|
||||
if (input.changes.name !== undefined && input.changes.name !== timeline.name) {
|
||||
Object.assign(changes, { name: input.changes.name });
|
||||
}
|
||||
if (
|
||||
input.changes.description !== undefined &&
|
||||
input.changes.description !== timeline.description
|
||||
) {
|
||||
Object.assign(changes, { description: input.changes.description });
|
||||
}
|
||||
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
|
||||
const next = input.changes[field];
|
||||
if (next !== undefined && (next?.getTime() ?? null) !== (timeline[field]?.getTime() ?? null)) {
|
||||
Object.assign(changes, { [field]: next });
|
||||
}
|
||||
}
|
||||
|
||||
// An exact no-op writes nothing and — crucially — does not bump the revision, so a client polling
|
||||
// on revision is not woken by an operator saving a form they did not change.
|
||||
if (Object.keys(changes).length === 0) {
|
||||
return current;
|
||||
}
|
||||
requireInterval(
|
||||
changes.eligibleFrom === undefined ? timeline.eligibleFrom : changes.eligibleFrom,
|
||||
changes.eligibleUntil === undefined ? timeline.eligibleUntil : changes.eligibleUntil,
|
||||
);
|
||||
await input.store.updateTimeline(input.timelineId, changes);
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a timeline, and refuses while playback still points at it.
|
||||
*
|
||||
* A timeline deleted out from under a running channel group would blank a wall mid-service. Refusing
|
||||
* with counts is something an operator can act on: activate something else first.
|
||||
*/
|
||||
export async function deleteTimeline(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
}): Promise<void> {
|
||||
await requireTimeline(input.store, input.timelineId);
|
||||
const usage = await input.store.timelineUsage(input.timelineId);
|
||||
if (usage.activeGroups > 0 || usage.pendingGroups > 0 || usage.scheduledGroups > 0) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||
activeGroups: usage.activeGroups,
|
||||
pendingGroups: usage.pendingGroups,
|
||||
scheduledGroups: usage.scheduledGroups,
|
||||
});
|
||||
}
|
||||
await input.store.deleteTimeline(input.timelineId);
|
||||
}
|
||||
|
||||
export async function addProviderSource(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly providerId: string;
|
||||
readonly sourceId: string;
|
||||
readonly label: string;
|
||||
readonly newId?: () => string;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const detail = await requireTimeline(input.store, input.timelineId);
|
||||
if (detail.sources.length >= SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||
maxSources: SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline,
|
||||
});
|
||||
}
|
||||
const newId = input.newId ?? randomUUID;
|
||||
await input.store.addSource({
|
||||
id: newId(),
|
||||
label: input.label,
|
||||
providerId: input.providerId,
|
||||
// Generated here, stable forever, and deliberately not derived from the timeline or slot id:
|
||||
// either would silently reset a provider's rotation when an operator renamed or reordered
|
||||
// something (ADR 0003).
|
||||
selectionScope: newId(),
|
||||
sourceId: input.sourceId,
|
||||
timelineId: input.timelineId,
|
||||
});
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
export async function removeProviderSource(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly sourceId: string;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const detail = await requireTimeline(input.store, input.timelineId);
|
||||
if (!detail.sources.some((source) => source.id === input.sourceId)) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
await input.store.removeSource(input.sourceId);
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
export interface AddSlotInput extends TimelineIdentitySource {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly position?: number | undefined;
|
||||
readonly enabled?: boolean | undefined;
|
||||
readonly durationOverrideMs?: number | null | undefined;
|
||||
readonly eligibleFrom?: Date | null | undefined;
|
||||
readonly eligibleUntil?: Date | null | undefined;
|
||||
readonly presentation?: Partial<StoredSlotPresentation> | undefined;
|
||||
/** Exactly one of these two shapes. */
|
||||
readonly source?: { readonly sourceId: string } | undefined;
|
||||
readonly pinned?: { readonly providerId: string; readonly itemId: string } | undefined;
|
||||
}
|
||||
|
||||
export async function addSlot(input: AddSlotInput): Promise<StoredTimelineDetail> {
|
||||
const detail = await requireTimeline(input.store, input.timelineId);
|
||||
if (detail.slots.length >= SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||
maxSlots: SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline,
|
||||
});
|
||||
}
|
||||
if ((input.source === undefined) === (input.pinned === undefined)) {
|
||||
// A slot is a provider source or a pinned item. Both or neither is not a slot.
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "kind" });
|
||||
}
|
||||
if (input.source && !detail.sources.some((source) => source.id === input.source?.sourceId)) {
|
||||
// A slot may only reference a source configured on its own timeline.
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
|
||||
}
|
||||
if (
|
||||
input.durationOverrideMs !== undefined &&
|
||||
input.durationOverrideMs !== null &&
|
||||
(!Number.isSafeInteger(input.durationOverrideMs) ||
|
||||
input.durationOverrideMs < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
|
||||
input.durationOverrideMs > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
|
||||
}
|
||||
requireInterval(input.eligibleFrom, input.eligibleUntil);
|
||||
|
||||
await input.store.addSlot(
|
||||
{
|
||||
durationOverrideMs: input.durationOverrideMs ?? null,
|
||||
eligibleFrom: input.eligibleFrom ?? null,
|
||||
eligibleUntil: input.eligibleUntil ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
id: nextId(input),
|
||||
itemId: input.pinned?.itemId ?? null,
|
||||
kind: input.source ? "provider-source" : "pinned-item",
|
||||
presentation: normalizePresentation(input.presentation),
|
||||
providerId: input.pinned?.providerId ?? null,
|
||||
sourceId: input.source?.sourceId ?? null,
|
||||
timelineId: input.timelineId,
|
||||
},
|
||||
input.position,
|
||||
);
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
export async function updateSlot(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly slotId: string;
|
||||
readonly changes: SlotChanges;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const detail = await requireTimeline(input.store, input.timelineId);
|
||||
const slot = detail.slots.find((candidate) => candidate.id === input.slotId);
|
||||
if (!slot) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
const changes: SlotChanges = {};
|
||||
if (input.changes.enabled !== undefined && input.changes.enabled !== slot.enabled) {
|
||||
Object.assign(changes, { enabled: input.changes.enabled });
|
||||
}
|
||||
if (
|
||||
input.changes.durationOverrideMs !== undefined &&
|
||||
input.changes.durationOverrideMs !== slot.durationOverrideMs
|
||||
) {
|
||||
const value = input.changes.durationOverrideMs;
|
||||
if (
|
||||
value !== null &&
|
||||
(!Number.isSafeInteger(value) ||
|
||||
value < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
|
||||
value > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
|
||||
}
|
||||
Object.assign(changes, { durationOverrideMs: value });
|
||||
}
|
||||
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
|
||||
const next = input.changes[field];
|
||||
if (next !== undefined && (next?.getTime() ?? null) !== (slot[field]?.getTime() ?? null)) {
|
||||
Object.assign(changes, { [field]: next });
|
||||
}
|
||||
}
|
||||
if (input.changes.itemId !== undefined && input.changes.itemId !== slot.itemId) {
|
||||
if (slot.kind !== "pinned-item") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "itemId" });
|
||||
}
|
||||
Object.assign(changes, { itemId: input.changes.itemId });
|
||||
}
|
||||
if (input.changes.presentation) {
|
||||
const merged = normalizePresentation({ ...slot.presentation, ...input.changes.presentation });
|
||||
const changed = (Object.keys(merged) as (keyof StoredSlotPresentation)[]).some(
|
||||
(key) => merged[key] !== slot.presentation[key],
|
||||
);
|
||||
if (changed) {
|
||||
Object.assign(changes, { presentation: merged });
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
return detail;
|
||||
}
|
||||
requireInterval(
|
||||
changes.eligibleFrom === undefined ? slot.eligibleFrom : changes.eligibleFrom,
|
||||
changes.eligibleUntil === undefined ? slot.eligibleUntil : changes.eligibleUntil,
|
||||
);
|
||||
await input.store.updateSlot(input.slotId, changes);
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
export async function removeSlot(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly slotId: string;
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
const detail = await requireTimeline(input.store, input.timelineId);
|
||||
if (!detail.slots.some((slot) => slot.id === input.slotId)) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
await input.store.removeSlot(input.slotId);
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
|
||||
export async function reorderSlots(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly timelineId: string;
|
||||
readonly orderedSlotIds: readonly string[];
|
||||
}): Promise<StoredTimelineDetail> {
|
||||
await requireTimeline(input.store, input.timelineId);
|
||||
try {
|
||||
await input.store.reorderSlots(input.timelineId, input.orderedSlotIds);
|
||||
} catch (error) {
|
||||
if (error instanceof SignageStoreConflict) {
|
||||
// The order was not exactly this timeline's current slots. Refusing is what makes a reorder
|
||||
// unable to lose a slot, duplicate one, or leave a hole in the pattern.
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return requireTimeline(input.store, input.timelineId);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SignageError } from "../errors.js";
|
||||
import type { SignageStore } from "../store/types.js";
|
||||
import { describeViewerHealth, updateViewerAssignment, type ViewerHealth } from "./service.js";
|
||||
|
||||
/**
|
||||
* `signage.displays` — sanitized viewer and channel health, and assignment, for another module.
|
||||
*
|
||||
* What a consuming module gets is exactly the roster an operator sees in the dashboard: names,
|
||||
* online state, assignment, what each screen last reported, and its most recent safe error code.
|
||||
*
|
||||
* What it deliberately does not get: a device credential, a pairing token, a session, a provider
|
||||
* payload, an object key, a delivery URL, or any private host internal. `deviceId` is the same
|
||||
* opaque correlation value Core exposes on a device principal — it identifies a screen, and it
|
||||
* unlocks nothing.
|
||||
*/
|
||||
export const SIGNAGE_DISPLAYS_CAPABILITY_VERSION = "1.0.0";
|
||||
|
||||
export interface SignageChannelSummary {
|
||||
readonly id: string;
|
||||
readonly groupId: string;
|
||||
readonly groupName: string;
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface SignageDisplaysCapability {
|
||||
readonly contractVersion: string;
|
||||
listChannels(): Promise<readonly SignageChannelSummary[]>;
|
||||
listViewers(): Promise<readonly ViewerHealth[]>;
|
||||
/** Moves a screen to another channel. Refuses a channel that does not exist rather than leaving a
|
||||
* display pointed at nothing. */
|
||||
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth>;
|
||||
renameViewer(viewerId: string, name: string): Promise<ViewerHealth>;
|
||||
}
|
||||
|
||||
export function createDisplaysCapability(
|
||||
store: () => SignageStore,
|
||||
now: () => Date,
|
||||
): SignageDisplaysCapability {
|
||||
async function health(
|
||||
viewerId: string,
|
||||
name?: string,
|
||||
channelId?: string,
|
||||
): Promise<ViewerHealth> {
|
||||
const viewer = await updateViewerAssignment({
|
||||
...(channelId === undefined ? {} : { channelId }),
|
||||
...(name === undefined ? {} : { name }),
|
||||
store: store(),
|
||||
viewerId,
|
||||
});
|
||||
return describeViewerHealth(viewer, now().getTime());
|
||||
}
|
||||
|
||||
const capability: SignageDisplaysCapability = {
|
||||
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth> {
|
||||
if (typeof channelId !== "string" || channelId.length === 0) {
|
||||
return Promise.reject(new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" }));
|
||||
}
|
||||
return health(viewerId, undefined, channelId);
|
||||
},
|
||||
contractVersion: SIGNAGE_DISPLAYS_CAPABILITY_VERSION,
|
||||
async listChannels(): Promise<readonly SignageChannelSummary[]> {
|
||||
const groups = await store().listChannelGroups();
|
||||
return groups.flatMap((group) =>
|
||||
group.channels.map((channel) => ({
|
||||
groupId: group.group.id,
|
||||
groupName: group.group.name,
|
||||
id: channel.id,
|
||||
isDefault: channel.isDefault,
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
})),
|
||||
);
|
||||
},
|
||||
async listViewers(): Promise<readonly ViewerHealth[]> {
|
||||
const atMs = now().getTime();
|
||||
const viewers = await store().listViewers();
|
||||
return viewers.map((viewer) => describeViewerHealth(viewer, atMs));
|
||||
},
|
||||
renameViewer(viewerId: string, name: string): Promise<ViewerHealth> {
|
||||
return health(viewerId, name, undefined);
|
||||
},
|
||||
};
|
||||
return Object.freeze(capability);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { SignageError } from "../errors.js";
|
||||
import type {
|
||||
SignageStore,
|
||||
StoredChannel,
|
||||
StoredViewer,
|
||||
StoredViewerError,
|
||||
ViewerChanges,
|
||||
} from "../store/types.js";
|
||||
|
||||
/**
|
||||
* Viewer registration, assignment, heartbeat, and health (ADR 0004).
|
||||
*
|
||||
* Two rules shape everything here.
|
||||
*
|
||||
* **A viewer never chooses its channel.** A display registers with the identity it keeps in its own
|
||||
* IndexedDB and lands on the default channel of the default group. An administrator moves it from
|
||||
* the dashboard. A public display that could select its own channel would let anyone who reaches the
|
||||
* page decide what a screen shows.
|
||||
*
|
||||
* **A heartbeat is telemetry, never authority.** What a viewer reports about what it is showing is
|
||||
* recorded so an operator can see it; it never changes playback, and a local cursor is never
|
||||
* accepted as truth.
|
||||
*/
|
||||
|
||||
/** The longest a viewer's own reported error detail may be. Bounded because it is operator-facing
|
||||
* text a display supplied, and because an unbounded one is a way to fill a table. */
|
||||
export const SIGNAGE_VIEWER_ERROR_DETAIL_MAX = 200;
|
||||
export const SIGNAGE_VIEWER_NAME_MAX = 120;
|
||||
/** A display is considered offline when nothing has been heard from it for this long. Comfortably
|
||||
* more than a few missed heartbeats, so a slow network does not make a working screen look dead. */
|
||||
export const SIGNAGE_VIEWER_OFFLINE_AFTER_MS = 90_000;
|
||||
|
||||
export interface ViewerSessionInput {
|
||||
readonly store: SignageStore;
|
||||
readonly viewerKey: string;
|
||||
/** The safe Core device identifier, when the principal is a paired device. Never a credential. */
|
||||
readonly deviceId: string | null;
|
||||
readonly at: Date;
|
||||
readonly newId?: () => string;
|
||||
}
|
||||
|
||||
export interface ViewerSession {
|
||||
readonly viewer: StoredViewer;
|
||||
readonly channel: StoredChannel;
|
||||
readonly groupId: string;
|
||||
}
|
||||
|
||||
const VIEWER_KEY = /^[\w-]{8,128}$/u;
|
||||
|
||||
export function isViewerKey(value: unknown): value is string {
|
||||
return typeof value === "string" && VIEWER_KEY.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a display, or recognizes one that has been here before.
|
||||
*
|
||||
* A returning viewer keeps its assignment, its name, and its history. Nothing about a reconnect
|
||||
* looks like a new screen, which is what keeps a display roster from filling with ghosts every time
|
||||
* a television reboots.
|
||||
*/
|
||||
export async function openViewerSession(input: ViewerSessionInput): Promise<ViewerSession> {
|
||||
if (!isViewerKey(input.viewerKey)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||
}
|
||||
const newId = input.newId ?? randomUUID;
|
||||
const groups = await input.store.listChannelGroups();
|
||||
const group = groups[0];
|
||||
if (!group) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
|
||||
}
|
||||
|
||||
const existing = await input.store.findViewerByKey(input.viewerKey);
|
||||
if (existing) {
|
||||
const changes: ViewerChanges = { lastSeenAt: input.at };
|
||||
if (input.deviceId !== null && input.deviceId !== existing.deviceId) {
|
||||
// A screen that was paired since it last checked in. The identifier is opaque and is stored
|
||||
// only so an operator can correlate the two rosters.
|
||||
Object.assign(changes, { deviceId: input.deviceId });
|
||||
}
|
||||
if (existing.channelId === null) {
|
||||
// Its channel was removed. It lands back on a default rather than rendering nothing.
|
||||
Object.assign(changes, { channelId: defaultChannelOf(group.channels).id });
|
||||
}
|
||||
await input.store.updateViewer(existing.id, changes);
|
||||
const refreshed = await input.store.findViewer(existing.id);
|
||||
if (!refreshed) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return resolveSession(input.store, refreshed);
|
||||
}
|
||||
|
||||
const channel = defaultChannelOf(group.channels);
|
||||
const viewer: StoredViewer = {
|
||||
cachedAssetCount: 0,
|
||||
channelId: channel.id,
|
||||
createdAt: input.at,
|
||||
deviceId: input.deviceId,
|
||||
id: newId(),
|
||||
lastError: null,
|
||||
lastSeenAt: input.at,
|
||||
name: "New display",
|
||||
offlineReady: false,
|
||||
reportedContributionId: null,
|
||||
reportedPlaybackRevision: null,
|
||||
reportedProviderId: null,
|
||||
reportedSelectionId: null,
|
||||
reportedTimelineId: null,
|
||||
viewerKey: input.viewerKey,
|
||||
};
|
||||
await input.store.insertViewer(viewer);
|
||||
await input.store.updateViewer(viewer.id, { lastSeenAt: input.at });
|
||||
return { channel, groupId: group.group.id, viewer };
|
||||
}
|
||||
|
||||
function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
|
||||
const fallback = channels.find((channel) => channel.isDefault) ?? channels[0];
|
||||
if (!fallback) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel" });
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
|
||||
const groups = await store.listChannelGroups();
|
||||
for (const group of groups) {
|
||||
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
||||
if (channel) {
|
||||
return { channel, groupId: group.group.id, viewer };
|
||||
}
|
||||
}
|
||||
const first = groups[0];
|
||||
if (!first) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
|
||||
}
|
||||
return { channel: defaultChannelOf(first.channels), groupId: first.group.id, viewer };
|
||||
}
|
||||
|
||||
export interface ViewerHeartbeat {
|
||||
readonly reportedTimelineId?: string | null | undefined;
|
||||
readonly reportedSelectionId?: string | null | undefined;
|
||||
readonly reportedProviderId?: string | null | undefined;
|
||||
readonly reportedContributionId?: string | null | undefined;
|
||||
readonly reportedPlaybackRevision?: number | null | undefined;
|
||||
readonly offlineReady?: boolean | undefined;
|
||||
readonly cachedAssetCount?: number | undefined;
|
||||
readonly error?: { readonly code: string; readonly detail?: string | null } | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a heartbeat, and **coalesces** it.
|
||||
*
|
||||
* A steady display checks in every few seconds. Writing a row every time would turn a wall of
|
||||
* televisions into a constant stream of PostgreSQL writes that says nothing new. So a write happens
|
||||
* only when something an operator would notice changed, or when enough time has passed that "last
|
||||
* seen" would otherwise go stale.
|
||||
*
|
||||
* Returns whether a write actually happened, so this is measurable rather than asserted.
|
||||
*/
|
||||
export async function recordViewerHeartbeat(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly viewerId: string;
|
||||
readonly heartbeat: ViewerHeartbeat;
|
||||
readonly at: Date;
|
||||
readonly minimumWriteIntervalMs: number;
|
||||
}): Promise<{ readonly written: boolean; readonly viewer: StoredViewer }> {
|
||||
const viewer = await input.store.findViewer(input.viewerId);
|
||||
if (!viewer) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const error = normalizeViewerError(input.heartbeat.error, input.at);
|
||||
const changed =
|
||||
fieldChanged(viewer.reportedTimelineId, input.heartbeat.reportedTimelineId) ||
|
||||
fieldChanged(viewer.reportedSelectionId, input.heartbeat.reportedSelectionId) ||
|
||||
fieldChanged(viewer.reportedProviderId, input.heartbeat.reportedProviderId) ||
|
||||
fieldChanged(viewer.reportedContributionId, input.heartbeat.reportedContributionId) ||
|
||||
fieldChanged(viewer.reportedPlaybackRevision, input.heartbeat.reportedPlaybackRevision) ||
|
||||
fieldChanged(viewer.offlineReady, input.heartbeat.offlineReady) ||
|
||||
fieldChanged(viewer.cachedAssetCount, input.heartbeat.cachedAssetCount) ||
|
||||
(viewer.lastError?.code ?? null) !== (error?.code ?? null);
|
||||
|
||||
const staleMs = input.at.getTime() - (viewer.lastSeenAt?.getTime() ?? 0);
|
||||
if (!changed && staleMs < input.minimumWriteIntervalMs) {
|
||||
// Nothing new, and "last seen" is recent enough to still be true. Say nothing to the database.
|
||||
return { viewer, written: false };
|
||||
}
|
||||
|
||||
const changes: ViewerChanges = { lastSeenAt: input.at };
|
||||
for (const key of [
|
||||
"cachedAssetCount",
|
||||
"offlineReady",
|
||||
"reportedContributionId",
|
||||
"reportedPlaybackRevision",
|
||||
"reportedProviderId",
|
||||
"reportedSelectionId",
|
||||
"reportedTimelineId",
|
||||
] as const) {
|
||||
const value = input.heartbeat[key];
|
||||
if (value !== undefined) {
|
||||
Object.assign(changes, { [key]: value });
|
||||
}
|
||||
}
|
||||
if (input.heartbeat.error !== undefined) {
|
||||
Object.assign(changes, { lastError: error });
|
||||
}
|
||||
await input.store.updateViewer(input.viewerId, changes);
|
||||
const updated = await input.store.findViewer(input.viewerId);
|
||||
if (!updated) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return { viewer: updated, written: true };
|
||||
}
|
||||
|
||||
function fieldChanged<Value>(current: Value, next: Value | undefined): boolean {
|
||||
return next !== undefined && next !== current;
|
||||
}
|
||||
|
||||
/** A display's own reported error, bounded and stripped. A code and a short detail — never a stack,
|
||||
* a URL, a payload, or anything else a browser might have had to hand. */
|
||||
function normalizeViewerError(error: ViewerHeartbeat["error"], at: Date): StoredViewerError | null {
|
||||
if (error === undefined || error === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof error.code !== "string" || error.code.length === 0 || error.code.length > 64) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
||||
}
|
||||
const detail = error.detail ?? null;
|
||||
if (
|
||||
detail !== null &&
|
||||
(typeof detail !== "string" || detail.length > SIGNAGE_VIEWER_ERROR_DETAIL_MAX)
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
||||
}
|
||||
return { at, code: error.code, detail };
|
||||
}
|
||||
|
||||
export interface ViewerHealth {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly online: boolean;
|
||||
readonly channelId: string | null;
|
||||
readonly deviceId: string | null;
|
||||
readonly lastSeenAtMs: number | null;
|
||||
readonly reportedTimelineId: string | null;
|
||||
readonly reportedSelectionId: string | null;
|
||||
readonly reportedProviderId: string | null;
|
||||
readonly reportedContributionId: string | null;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly lastError: {
|
||||
readonly code: string;
|
||||
readonly detail: string | null;
|
||||
readonly atMs: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** What management sees. Everything is either Signage's own record or a value the display itself
|
||||
* reported through the bounded heartbeat contract — never a credential and never a host internal. */
|
||||
export function describeViewerHealth(viewer: StoredViewer, atMs: number): ViewerHealth {
|
||||
const lastSeenAtMs = viewer.lastSeenAt?.getTime() ?? null;
|
||||
return {
|
||||
cachedAssetCount: viewer.cachedAssetCount,
|
||||
channelId: viewer.channelId,
|
||||
deviceId: viewer.deviceId,
|
||||
id: viewer.id,
|
||||
lastError:
|
||||
viewer.lastError === null
|
||||
? null
|
||||
: {
|
||||
atMs: viewer.lastError.at.getTime(),
|
||||
code: viewer.lastError.code,
|
||||
detail: viewer.lastError.detail,
|
||||
},
|
||||
lastSeenAtMs,
|
||||
name: viewer.name,
|
||||
offlineReady: viewer.offlineReady,
|
||||
online: lastSeenAtMs !== null && atMs - lastSeenAtMs < SIGNAGE_VIEWER_OFFLINE_AFTER_MS,
|
||||
reportedContributionId: viewer.reportedContributionId,
|
||||
reportedProviderId: viewer.reportedProviderId,
|
||||
reportedSelectionId: viewer.reportedSelectionId,
|
||||
reportedTimelineId: viewer.reportedTimelineId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Assigns a viewer to a channel, or renames it. Refuses a channel that does not exist rather than
|
||||
* silently leaving a screen unassigned. */
|
||||
export async function updateViewerAssignment(input: {
|
||||
readonly store: SignageStore;
|
||||
readonly viewerId: string;
|
||||
readonly name?: string | undefined;
|
||||
readonly channelId?: string | undefined;
|
||||
}): Promise<StoredViewer> {
|
||||
const viewer = await input.store.findViewer(input.viewerId);
|
||||
if (!viewer) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
const changes: ViewerChanges = {};
|
||||
if (input.name !== undefined && input.name !== viewer.name) {
|
||||
if (input.name.length === 0 || input.name.length > SIGNAGE_VIEWER_NAME_MAX) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "name" });
|
||||
}
|
||||
Object.assign(changes, { name: input.name });
|
||||
}
|
||||
if (input.channelId !== undefined && input.channelId !== viewer.channelId) {
|
||||
const groups = await input.store.listChannelGroups();
|
||||
const exists = groups.some((group) =>
|
||||
group.channels.some((channel) => channel.id === input.channelId),
|
||||
);
|
||||
if (!exists) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
Object.assign(changes, { channelId: input.channelId });
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
return viewer;
|
||||
}
|
||||
await input.store.updateViewer(input.viewerId, changes);
|
||||
const updated = await input.store.findViewer(input.viewerId);
|
||||
if (!updated) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Everything about a native Signage image that both halves of the module must agree on.
|
||||
*
|
||||
* Import-free on purpose: this file is compiled into the Node server project *and* into the browser
|
||||
* artifacts, so the server's stored presentation and the display's painted presentation are resolved
|
||||
* by the same function rather than by two implementations that drift.
|
||||
*/
|
||||
|
||||
/** The identity Signage's own built-in content provider registers under. It is an ordinary provider
|
||||
* from every other part of the system's point of view (ADR 0001), which is why there is exactly one
|
||||
* selection pipeline instead of a native special case. */
|
||||
export const SIGNAGE_NATIVE_PROVIDER_ID = "signage.native-images";
|
||||
|
||||
/** The renderer identity a native image resolves to. Native images are drawn by Signage's own
|
||||
* display code rather than by a contributed renderer, so this is deliberately not a contribution
|
||||
* id — a display that sees it renders an image element instead of asking the host to mount
|
||||
* anything. */
|
||||
export const SIGNAGE_NATIVE_RENDERER = "signage.native-image";
|
||||
|
||||
export const SIGNAGE_ASPECT_16_9 = 16 / 9;
|
||||
|
||||
/**
|
||||
* How far from exactly 16:9 an image may be and still be treated as filling the frame.
|
||||
*
|
||||
* 0.02 accepts the ratios real export pipelines actually produce — 1920×1080 exactly, 1918×1080 from
|
||||
* a cropped canvas, 1280×720 — and rejects anything a viewer would see letterboxed. It is a
|
||||
* documented tolerance rather than an exact comparison because an exact one would put a 1919-pixel
|
||||
* export on a blurred background for no reason a person could see.
|
||||
*/
|
||||
export const SIGNAGE_ASPECT_TOLERANCE = 0.02;
|
||||
|
||||
export function isSixteenByNine(width: number, height: number): boolean {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(width / height - SIGNAGE_ASPECT_16_9) <= SIGNAGE_ASPECT_TOLERANCE;
|
||||
}
|
||||
|
||||
export type SignageImageFit = "fill" | "contain";
|
||||
|
||||
/** What a scene layer needs to paint one native image. Every value is already resolved: the layer
|
||||
* makes no decisions of its own. */
|
||||
export interface SignageNativeScene {
|
||||
readonly fit: SignageImageFit;
|
||||
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
|
||||
readonly backgroundColor: string;
|
||||
readonly backgroundImage: boolean;
|
||||
readonly blurPixels: number;
|
||||
readonly darkenOpacity: number;
|
||||
readonly shadow: boolean;
|
||||
}
|
||||
|
||||
/** The presentation values a slot may override, already merged with the installation defaults by
|
||||
* the server. */
|
||||
export interface SignageNativePresentation {
|
||||
readonly backgroundMode: "blurred" | "solid" | "black";
|
||||
readonly backgroundColor: string;
|
||||
readonly backgroundDarkened: boolean;
|
||||
readonly blurPixels: number;
|
||||
readonly foregroundShadow: boolean;
|
||||
}
|
||||
|
||||
/** How much a darkened background is dimmed. One value rather than a setting: an operator asking for
|
||||
* "darker" wants the text readable, not a dimmer they have to tune. */
|
||||
export const SIGNAGE_DARKEN_OPACITY = 0.25;
|
||||
|
||||
/**
|
||||
* Resolves one native image plus its slot's presentation into everything a scene layer needs.
|
||||
*
|
||||
* A true 16:9 image covers the whole frame on its own — no blurred duplicate, no pillarbox, and no
|
||||
* floating-card shadow, all of which would be artefacts on an image that already fits. Anything else
|
||||
* is shown **whole** and backed, because cropping a church's slide to fill a screen cuts words off it.
|
||||
*/
|
||||
export function resolveNativeScene(
|
||||
image: { readonly width: number; readonly height: number },
|
||||
presentation: SignageNativePresentation,
|
||||
): SignageNativeScene {
|
||||
if (isSixteenByNine(image.width, image.height)) {
|
||||
return {
|
||||
backgroundColor: "#000000",
|
||||
backgroundImage: false,
|
||||
backgroundMode: "none",
|
||||
blurPixels: 0,
|
||||
darkenOpacity: 0,
|
||||
fit: "fill",
|
||||
shadow: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
backgroundColor: presentation.backgroundColor,
|
||||
backgroundImage: presentation.backgroundMode === "blurred",
|
||||
backgroundMode: presentation.backgroundMode,
|
||||
blurPixels: presentation.backgroundMode === "blurred" ? presentation.blurPixels : 0,
|
||||
darkenOpacity: presentation.backgroundDarkened ? SIGNAGE_DARKEN_OPACITY : 0,
|
||||
fit: "contain",
|
||||
shadow: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* The arithmetic of authoritative playback (ADR 0005).
|
||||
*
|
||||
* Import-free, and compiled into **both** halves of the module on purpose: the server reconciles a
|
||||
* channel group's clock with these functions, and a disconnected display continues from its last
|
||||
* authoritative anchor with the *same* functions. Two implementations of this would drift, and the
|
||||
* drift would only ever be visible on a wall, hours into an outage, with nobody watching.
|
||||
*
|
||||
* Three properties are what this file exists to guarantee, and each is the one a naive
|
||||
* implementation loses:
|
||||
*
|
||||
* 1. **Absolute instants, never a countdown.** A slot has a start and a deadline; a timer is only a
|
||||
* hint about when to look again.
|
||||
* 2. **Reconcile forward from the previous authoritative deadline**, never from "now". A callback
|
||||
* that fired 40 ms late costs 40 ms once, not 40 ms on every slide for the rest of the day.
|
||||
* 3. **Catch up by arithmetic.** A screen that was off for a week must not step through a week of
|
||||
* slides — and must not consume a provider's rotation doing it.
|
||||
*/
|
||||
|
||||
/** One slot, reduced to exactly what timing needs. */
|
||||
export interface TimingSlot {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly enabled: boolean;
|
||||
/** Half-open `[from, until)`, in absolute epoch milliseconds. */
|
||||
readonly eligibleFromMs: number | null;
|
||||
readonly eligibleUntilMs: number | null;
|
||||
/**
|
||||
* What this slot is *expected* to last, from its own override, its provider's registered default,
|
||||
* or the native-image default.
|
||||
*
|
||||
* An estimate, deliberately: the real duration of a provider slot is only known once the provider
|
||||
* has resolved, and resolving thousands of times to catch up is exactly what this file avoids. The
|
||||
* estimate decides how far to skip; the resolution decides the deadline of the slot actually
|
||||
* landed on.
|
||||
*/
|
||||
readonly estimatedDurationMs: number;
|
||||
}
|
||||
|
||||
export interface TimingTimeline {
|
||||
readonly id: string;
|
||||
readonly eligibleFromMs: number | null;
|
||||
readonly eligibleUntilMs: number | null;
|
||||
/** In position order. */
|
||||
readonly slots: readonly TimingSlot[];
|
||||
}
|
||||
|
||||
/** Where a channel group's clock currently is. */
|
||||
export interface TimingCursor {
|
||||
readonly slotId: string | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
}
|
||||
|
||||
export type TimingAdvance =
|
||||
/** The current slot is still running. Nothing to do until its deadline. */
|
||||
| { readonly kind: "hold" }
|
||||
/** Move to this slot, starting exactly here. */
|
||||
| {
|
||||
readonly kind: "advance";
|
||||
readonly slotId: string;
|
||||
readonly startedAtMs: number;
|
||||
readonly deadlineAtMs: number;
|
||||
/** How many slot boundaries were crossed to get here. Zero for an ordinary transition; large
|
||||
* after an outage, and reported so a caller can tell "the next slide" from "catching up". */
|
||||
readonly skipped: number;
|
||||
}
|
||||
/** Nothing in this timeline is eligible right now. */
|
||||
| { readonly kind: "empty" };
|
||||
|
||||
/** A safety limit on any walk. Reaching it means a bug, not a legitimate schedule, and stopping is
|
||||
* better than spinning on a wall. */
|
||||
export const TIMING_TRANSITION_LIMIT = 10_000;
|
||||
|
||||
export function isTimelineEligible(timeline: TimingTimeline, atMs: number): boolean {
|
||||
if (timeline.eligibleFromMs !== null && atMs < timeline.eligibleFromMs) {
|
||||
return false;
|
||||
}
|
||||
return timeline.eligibleUntilMs === null || atMs < timeline.eligibleUntilMs;
|
||||
}
|
||||
|
||||
/** Half-open `[from, until)`: eligible exactly at `from`, ineligible exactly at `until`. It is the
|
||||
* only convention under which two adjacent windows neither overlap nor leave a gap. */
|
||||
export function isSlotEligible(slot: TimingSlot, atMs: number): boolean {
|
||||
if (!slot.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (slot.eligibleFromMs !== null && atMs < slot.eligibleFromMs) {
|
||||
return false;
|
||||
}
|
||||
return slot.eligibleUntilMs === null || atMs < slot.eligibleUntilMs;
|
||||
}
|
||||
|
||||
export function eligibleSlots(timeline: TimingTimeline, atMs: number): readonly TimingSlot[] {
|
||||
if (!isTimelineEligible(timeline, atMs)) {
|
||||
return [];
|
||||
}
|
||||
return timeline.slots.filter((slot) => isSlotEligible(slot, atMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first instant in `(after, through]` at which any slot's eligibility can change.
|
||||
*
|
||||
* Between two such instants the eligible set is constant, which is exactly what makes skipping whole
|
||||
* passes of the pattern safe. Without this, a catch-up would happily skip past a slot that was
|
||||
* scheduled to appear during the outage.
|
||||
*/
|
||||
export function firstEligibilityBoundary(
|
||||
timeline: TimingTimeline,
|
||||
afterMs: number,
|
||||
throughMs: number,
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
const candidates: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
|
||||
for (const slot of timeline.slots) {
|
||||
candidates.push(slot.eligibleFromMs, slot.eligibleUntilMs);
|
||||
}
|
||||
for (const value of candidates) {
|
||||
if (value === null || value <= afterMs || value > throughMs) {
|
||||
continue;
|
||||
}
|
||||
if (best === null || value < best) {
|
||||
best = value;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A currently showing slot can be cut short by **its own** window, never by another slot's. */
|
||||
function ownInvalidBoundary(
|
||||
slot: TimingSlot | undefined,
|
||||
startedAtMs: number,
|
||||
throughMs: number,
|
||||
): number | null {
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
const value = slot.eligibleUntilMs;
|
||||
if (value === null || value <= startedAtMs || value > throughMs) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nextEligibleAfter(
|
||||
timeline: TimingTimeline,
|
||||
currentSlotId: string | null,
|
||||
atMs: number,
|
||||
): TimingSlot | null {
|
||||
const eligible = eligibleSlots(timeline, atMs);
|
||||
if (eligible.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentSlotId === null) {
|
||||
return eligible[0] as TimingSlot;
|
||||
}
|
||||
const current = timeline.slots.find((slot) => slot.id === currentSlotId);
|
||||
if (!current) {
|
||||
return eligible[0] as TimingSlot;
|
||||
}
|
||||
// The pattern repeats, so "after" wraps. Position — not eligibility order — is what an operator
|
||||
// arranged, so the successor is the next *position* that happens to be eligible.
|
||||
const after = eligible.find((slot) => slot.position > current.position);
|
||||
return after ?? (eligible[0] as TimingSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* The next instant a display or a channel group must look again.
|
||||
*
|
||||
* The current slot's deadline, or the next eligibility boundary when nothing is running. `null`
|
||||
* means there is nothing scheduled to change — an empty timeline with no future windows — and a
|
||||
* caller should wait to be told rather than poll.
|
||||
*/
|
||||
export function nextTimingBoundary(
|
||||
timeline: TimingTimeline,
|
||||
cursor: TimingCursor,
|
||||
atMs: number,
|
||||
): number | null {
|
||||
const candidates: number[] = [];
|
||||
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs > atMs) {
|
||||
candidates.push(cursor.deadlineAtMs);
|
||||
}
|
||||
const boundaries: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
|
||||
for (const slot of timeline.slots) {
|
||||
boundaries.push(slot.eligibleFromMs, slot.eligibleUntilMs);
|
||||
}
|
||||
for (const value of boundaries) {
|
||||
if (value !== null && value > atMs) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
return candidates.length === 0 ? null : Math.min(...candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a cursor forward to `atMs`.
|
||||
*
|
||||
* The heart of it is the boundary choice: when a slot's deadline has passed, the next slot starts at
|
||||
* **that deadline**, not at `atMs`. That single decision is what keeps a loop from stretching by the
|
||||
* accumulated lateness of every timer that ever fired.
|
||||
*
|
||||
* Long gaps are crossed with arithmetic. Within one stretch where the eligible set cannot change,
|
||||
* whole passes of the pattern are skipped by division rather than by stepping — so a week offline
|
||||
* costs one reconciliation and one provider call, not ten thousand of each.
|
||||
*/
|
||||
export function advanceCursor(
|
||||
timeline: TimingTimeline,
|
||||
cursor: TimingCursor,
|
||||
atMs: number,
|
||||
): TimingAdvance {
|
||||
const current =
|
||||
cursor.slotId === null ? undefined : timeline.slots.find((s) => s.id === cursor.slotId);
|
||||
|
||||
// Still running, and still allowed to be: nothing to decide.
|
||||
if (
|
||||
current &&
|
||||
cursor.deadlineAtMs !== null &&
|
||||
cursor.deadlineAtMs > atMs &&
|
||||
isSlotEligible(current, atMs) &&
|
||||
isTimelineEligible(timeline, atMs)
|
||||
) {
|
||||
return { kind: "hold" };
|
||||
}
|
||||
|
||||
// Where the next slot legitimately begins.
|
||||
let boundary = atMs;
|
||||
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs <= atMs) {
|
||||
boundary = cursor.deadlineAtMs;
|
||||
}
|
||||
const invalidAt = ownInvalidBoundary(current, cursor.startedAtMs ?? boundary, atMs);
|
||||
if (invalidAt !== null) {
|
||||
boundary = Math.min(boundary, invalidAt);
|
||||
}
|
||||
|
||||
let slotId = cursor.slotId;
|
||||
let startedAtMs = boundary;
|
||||
let skipped = 0;
|
||||
|
||||
for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) {
|
||||
const next = nextEligibleAfter(timeline, slotId, startedAtMs);
|
||||
if (!next) {
|
||||
// Nothing eligible at this instant. If something becomes eligible later in the gap, jump
|
||||
// straight to that instant rather than declaring the timeline empty for the whole outage.
|
||||
const boundaryAhead = firstEligibilityBoundary(timeline, startedAtMs, atMs);
|
||||
if (boundaryAhead === null) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
startedAtMs = boundaryAhead;
|
||||
slotId = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deadlineAtMs = startedAtMs + next.estimatedDurationMs;
|
||||
if (deadlineAtMs > atMs) {
|
||||
return { deadlineAtMs, kind: "advance", skipped, slotId: next.id, startedAtMs };
|
||||
}
|
||||
|
||||
// The landed-on slot is already over. Skip whole passes of the pattern with a division, but only
|
||||
// as far as the millisecond before the next instant the eligible set could change.
|
||||
const changeAt = firstEligibilityBoundary(timeline, startedAtMs, atMs);
|
||||
const through = changeAt === null ? atMs : changeAt - 1;
|
||||
const stable = eligibleSlots(timeline, startedAtMs);
|
||||
const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0);
|
||||
if (passDuration > 0 && deadlineAtMs <= through) {
|
||||
const passes = Math.floor((through - startedAtMs) / passDuration);
|
||||
if (passes > 0) {
|
||||
startedAtMs += passes * passDuration;
|
||||
skipped += passes * stable.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
startedAtMs = deadlineAtMs;
|
||||
slotId = next.id;
|
||||
skipped += 1;
|
||||
}
|
||||
|
||||
// Unreachable for any legitimate schedule. Holding is safer on a wall than spinning.
|
||||
return { kind: "hold" };
|
||||
}
|
||||
|
||||
/** How much of a running slot is left. Never negative, and `null` when nothing is running. */
|
||||
export function remainingMs(cursor: TimingCursor, atMs: number): number | null {
|
||||
if (cursor.deadlineAtMs === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, cursor.deadlineAtMs - atMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a resumed slot's window sits.
|
||||
*
|
||||
* The deadline is `now + remainder`, and the start is reconstructed **backwards** from it so the
|
||||
* whole-slot window stays consistent — a renderer computing progress from `start` and `deadline`
|
||||
* gets the same answer it would have got had the slot never been paused. A remainder captured under
|
||||
* a longer duration is clamped, so the reconstructed start can never land after the resume instant.
|
||||
*/
|
||||
export function resumeWindow(
|
||||
atMs: number,
|
||||
remainderMs: number,
|
||||
fullDurationMs: number,
|
||||
): { readonly startedAtMs: number; readonly deadlineAtMs: number } {
|
||||
const remaining = Math.max(0, Math.min(remainderMs, fullDurationMs));
|
||||
const deadlineAtMs = atMs + remaining;
|
||||
return { deadlineAtMs, startedAtMs: deadlineAtMs - fullDurationMs };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The versioned input Signage hands a provider's browser renderer (ADR 0002, ADR 0006).
|
||||
*
|
||||
* This is the domain contract behind Signage's `signage.slide-renderer` capability. The platform
|
||||
* carries it as opaque JSON and validates only that it *is* JSON (ADR 0041); what it means is
|
||||
* entirely between Signage and the contributing module, and it is versioned by Signage's own
|
||||
* capability version so a renderer written against v1 keeps working when v2 exists.
|
||||
*
|
||||
* Import-free, because it is compiled into both the Node server that builds it and the browser
|
||||
* artifacts that consume it.
|
||||
*
|
||||
* ## What is deliberately absent
|
||||
*
|
||||
* No API client, no realtime subscription, no principal, no permissions, no configuration, no
|
||||
* database, no storage, no credential, and no URL that is not already inside `payload`. Mounting a
|
||||
* renderer grants its module nothing: everything it needs arrives here, because Signage is the party
|
||||
* that was authorized to obtain it. A renderer that needed to reach the network would need a later
|
||||
* contract version, and this one does not weaken to accommodate it.
|
||||
*/
|
||||
|
||||
export const SIGNAGE_SLIDE_RENDERER_CONTRACT = "signage.slide-renderer";
|
||||
|
||||
/** Bumped when the shape below changes in a way a renderer can observe. */
|
||||
export const SIGNAGE_SLIDE_RENDERER_INPUT_VERSION = "1.0.0";
|
||||
|
||||
/** Both clocks at the moment this input was built, so a renderer can compute the authoritative time
|
||||
* without trusting the local one.
|
||||
*
|
||||
* A television's clock is routinely wrong — sometimes by hours. `serverNowMs` is what the server
|
||||
* said the time was; `localNowMs` is what `Date.now()` said at the same instant on this device. A
|
||||
* renderer that wants the authoritative now computes `serverNowMs + (Date.now() - localNowMs)`,
|
||||
* which is correct regardless of how far off the device's clock is, and stays correct across a
|
||||
* pause because both anchors move together. */
|
||||
export interface SignageRendererClock {
|
||||
readonly serverNowMs: number;
|
||||
readonly localNowMs: number;
|
||||
}
|
||||
|
||||
/** The authoritative slot window. A renderer may do anything it likes inside it and may not extend
|
||||
* it: the deadline was set by Signage before the renderer mounted. */
|
||||
export interface SignageRendererTiming {
|
||||
/** Absolute epoch milliseconds, on the server's clock. */
|
||||
readonly startedAtMs: number;
|
||||
readonly deadlineAtMs: number;
|
||||
readonly durationMs: number;
|
||||
readonly paused: boolean;
|
||||
/** How much of the slot is left when paused. `null` while running — the deadline is the answer. */
|
||||
readonly remainingMs: number | null;
|
||||
}
|
||||
|
||||
export interface SignageSlideRendererInput {
|
||||
readonly contract: typeof SIGNAGE_SLIDE_RENDERER_CONTRACT;
|
||||
readonly version: string;
|
||||
/** Which provider's content this is. Opaque; a renderer already knows its own. */
|
||||
readonly providerId: string;
|
||||
/** The provider's own identity for this content, and the revision of it being rendered. */
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
/** The audience this channel renders. A renderer that has nothing audience-specific ignores it. */
|
||||
readonly audienceKey: string;
|
||||
/** The provider's own payload, exactly as it returned it. */
|
||||
readonly payload: unknown;
|
||||
/** Presentation values the provider supplied to its own renderer, or `null`. Signage never
|
||||
* interprets, merges, or overrides them, and never restyles a provider's output. */
|
||||
readonly presentation: unknown;
|
||||
readonly timing: SignageRendererTiming;
|
||||
readonly clock: SignageRendererClock;
|
||||
/**
|
||||
* Changes whenever anything in this input changed.
|
||||
*
|
||||
* A renderer that keeps internal state keyed on it can tell "the same slide, updated" from "a
|
||||
* different slide" without comparing the whole input — which is exactly the distinction that lets
|
||||
* a cancelled event update a mounted React tree instead of restarting its slot.
|
||||
*/
|
||||
readonly generation: string;
|
||||
}
|
||||
|
||||
/** Builds the generation token. Deterministic, so two displays that received the same state compute
|
||||
* the same token and neither remounts when the other does not. */
|
||||
export function signageRendererGeneration(input: {
|
||||
readonly selectionId: string;
|
||||
readonly revision: number;
|
||||
readonly audienceKey: string;
|
||||
readonly paused: boolean;
|
||||
}): string {
|
||||
return `${input.selectionId}:${input.revision}:${input.audienceKey}:${input.paused ? "p" : "r"}`;
|
||||
}
|
||||
|
||||
/** Whether two inputs describe the same logical slide, so a consumer can update in place rather than
|
||||
* remounting. Identity — not payload equality — is what decides. */
|
||||
export function isSameSignageSlide(
|
||||
left: SignageSlideRendererInput,
|
||||
right: SignageSlideRendererInput,
|
||||
): boolean {
|
||||
return (
|
||||
left.providerId === right.providerId &&
|
||||
left.itemId === right.itemId &&
|
||||
left.audienceKey === right.audienceKey
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Signage's own operator settings, as one plain, JSON-safe shape.
|
||||
*
|
||||
* Deliberately **import-free**. This file is compiled into both halves of the module: the Node
|
||||
* server project and the browser project, whose TypeScript configuration declares `"types": []` so
|
||||
* that `process.env` fails to compile. Importing `@ekklesia/module-sdk` here would pull the backend
|
||||
* contracts barrel — and with it Drizzle's types and Node's globals — into every browser artifact,
|
||||
* which is exactly the boundary ADR 0024 closed. Reading these values from the platform's
|
||||
* configuration contract is therefore the server's job (`src/server/settings.ts`).
|
||||
*
|
||||
* Every value here is an **operator setting**: it survives restarts and is immutable for the life of
|
||||
* the process (ADR 0029). Domain state — which timeline is active, which slot is on screen, whether
|
||||
* playback is paused — lives in Signage's own tables instead, because those must change without one.
|
||||
*/
|
||||
|
||||
/** How a native image that is not 16:9 is backed. External providers own their own rendering
|
||||
* completely and are never subject to these values. */
|
||||
export const SIGNAGE_BACKGROUND_MODES = ["blurred", "solid", "black"] as const;
|
||||
|
||||
export type SignageBackgroundMode = (typeof SIGNAGE_BACKGROUND_MODES)[number];
|
||||
|
||||
export function isSignageBackgroundMode(value: unknown): value is SignageBackgroundMode {
|
||||
return (
|
||||
typeof value === "string" && (SIGNAGE_BACKGROUND_MODES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** The presentation a native image falls back to when a slot overrides nothing. */
|
||||
export interface SignageNativePresentationDefaults {
|
||||
readonly backgroundMode: SignageBackgroundMode;
|
||||
readonly backgroundColor: string;
|
||||
readonly backgroundDarkened: boolean;
|
||||
readonly blurPixels: number;
|
||||
}
|
||||
|
||||
export interface SignageSettings {
|
||||
/** IANA zone every schedule in this installation is written and displayed in. Signage never
|
||||
* interprets an ambiguous local timestamp: stored instants are absolute, and this exists so a
|
||||
* *person* reading or writing one sees the zone the church actually keeps. */
|
||||
readonly timezone: string;
|
||||
readonly nativeImageDurationMs: number;
|
||||
readonly crossfadeMs: number;
|
||||
readonly nativePresentation: SignageNativePresentationDefaults;
|
||||
/** How long one content-provider call may take before Signage treats it as a temporary failure. */
|
||||
readonly providerTimeoutMs: number;
|
||||
/** How many resolved selections per channel group are retained as playback history. */
|
||||
readonly playbackHistoryLimit: number;
|
||||
/** Shortest interval between two persisted heartbeat writes for one steady viewer. */
|
||||
readonly viewerHeartbeatWriteMs: number;
|
||||
}
|
||||
|
||||
export const SIGNAGE_HEX_COLOR = /^#[0-9a-f]{6}$/iu;
|
||||
|
||||
/** What Signage falls back to when a host supplies no configuration at all — the same values the
|
||||
* manifest declares as defaults, so the two can never disagree about what "unset" means. */
|
||||
export const SIGNAGE_DEFAULT_SETTINGS: SignageSettings = Object.freeze({
|
||||
crossfadeMs: 1000,
|
||||
nativeImageDurationMs: 7000,
|
||||
nativePresentation: Object.freeze({
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 20,
|
||||
}),
|
||||
playbackHistoryLimit: 50,
|
||||
providerTimeoutMs: 2000,
|
||||
timezone: "America/Chicago",
|
||||
viewerHeartbeatWriteMs: 30_000,
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
|
||||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import { DesktopOnly } from "../browser/desktop-gate.js";
|
||||
import { createSignageApi } from "./api.js";
|
||||
import { useResource } from "./hooks.js";
|
||||
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||
import { ProvidersPanel } from "./panels/ProvidersPanel.js";
|
||||
import { renderLoadable } from "./panels/shared.js";
|
||||
import { TimelinesPanel } from "./panels/TimelinesPanel.js";
|
||||
import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* The Signage management screen.
|
||||
*
|
||||
* Host-neutral by construction: every value comes from the host context, and nothing here hardcodes
|
||||
* a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus
|
||||
* Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour.
|
||||
*
|
||||
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
||||
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||
*/
|
||||
export function SignageDashboard({
|
||||
context,
|
||||
}: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
}): ReactElement {
|
||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||
[mayView],
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedGroupId =
|
||||
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? (timelineId) => {
|
||||
void api
|
||||
.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||
timelineId,
|
||||
when: "now",
|
||||
})
|
||||
.then(() => setTab("playback"))
|
||||
.catch(() => setTab("playback"));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
{
|
||||
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* Signage's dashboard talking to Signage's own backend.
|
||||
*
|
||||
* Every path here is **relative to this module's own API root**, and the host resolves it. Nothing
|
||||
* in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment
|
||||
* detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else.
|
||||
*
|
||||
* Failures become one small, typed shape. The server already sanitizes what it says; this keeps a
|
||||
* network error and a refusal indistinguishable in the places where they should be — a screen that
|
||||
* cannot load — and distinguishable where they matter, in a form that can show *why* a save was
|
||||
* refused.
|
||||
*/
|
||||
|
||||
export interface SignageApiFailure {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
readonly detail?: Readonly<Record<string, number | string | null>> | undefined;
|
||||
}
|
||||
|
||||
export class SignageRequestError extends Error {
|
||||
readonly failure: SignageApiFailure;
|
||||
|
||||
constructor(failure: SignageApiFailure) {
|
||||
super(failure.message);
|
||||
this.name = "SignageRequestError";
|
||||
this.failure = failure;
|
||||
}
|
||||
}
|
||||
|
||||
const GENERIC: SignageApiFailure = {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
};
|
||||
|
||||
async function readFailure(response: Response): Promise<SignageApiFailure> {
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
if (typeof body.code === "string" && typeof body.message === "string") {
|
||||
return {
|
||||
code: body.code,
|
||||
...(typeof body.detail === "object" && body.detail !== null
|
||||
? { detail: body.detail as SignageApiFailure["detail"] }
|
||||
: {}),
|
||||
message: body.message,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A response that is not Signage's own error shape tells the operator nothing useful, and
|
||||
// guessing at it would put someone else's words on the screen.
|
||||
}
|
||||
return { ...GENERIC, status: response.status };
|
||||
}
|
||||
|
||||
export interface SignageApi {
|
||||
get<Result>(path: string): Promise<Result>;
|
||||
send<Result>(
|
||||
method: "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Result>;
|
||||
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
|
||||
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
|
||||
}
|
||||
|
||||
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
|
||||
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await client.request(path, init);
|
||||
} catch {
|
||||
throw new SignageRequestError(GENERIC);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SignageRequestError(await readFailure(response));
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as Result;
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as Result;
|
||||
} catch {
|
||||
throw new SignageRequestError({ ...GENERIC, status: response.status });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path) => request(path, undefined),
|
||||
send: (method, path, body) =>
|
||||
request(path, {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||
method,
|
||||
}),
|
||||
upload: (path, bytes, contentType) =>
|
||||
request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }),
|
||||
};
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
||||
|
||||
/**
|
||||
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
|
||||
*
|
||||
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
|
||||
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
|
||||
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
|
||||
* sentences.
|
||||
*/
|
||||
export type Loadable<Value> =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "ready"; readonly value: Value }
|
||||
| { readonly status: "failed"; readonly failure: SignageApiFailure };
|
||||
|
||||
export interface Resource<Value> {
|
||||
readonly state: Loadable<Value>;
|
||||
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
|
||||
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
|
||||
reload(): void;
|
||||
}
|
||||
|
||||
export function useResource<Value>(
|
||||
load: () => Promise<Value>,
|
||||
dependencies: readonly unknown[],
|
||||
): Resource<Value> {
|
||||
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
|
||||
const generation = useRef(0);
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
|
||||
const run = useCallback(() => {
|
||||
generation.current += 1;
|
||||
const token = generation.current;
|
||||
void loadRef
|
||||
.current()
|
||||
.then((value) => {
|
||||
// A response from a request the panel has already moved on from must never overwrite a
|
||||
// newer one.
|
||||
if (token === generation.current) {
|
||||
setState({ status: "ready", value });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (token !== generation.current) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
failure:
|
||||
error instanceof SignageRequestError
|
||||
? error.failure
|
||||
: {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
},
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
||||
// change, which is the whole reason this takes one.
|
||||
useEffect(run, [run, ...dependencies]);
|
||||
|
||||
return { reload: run, state };
|
||||
}
|
||||
|
||||
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
|
||||
* can say why a save was refused. */
|
||||
export interface Action {
|
||||
readonly pending: boolean;
|
||||
readonly failure: SignageApiFailure | undefined;
|
||||
run(work: () => Promise<unknown>): void;
|
||||
clearFailure(): void;
|
||||
}
|
||||
|
||||
export function useAction(onSettled?: () => void): Action {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
|
||||
const settled = useRef(onSettled);
|
||||
settled.current = onSettled;
|
||||
|
||||
const run = useCallback((work: () => Promise<unknown>) => {
|
||||
setPending(true);
|
||||
setFailure(undefined);
|
||||
void work()
|
||||
.then(() => {
|
||||
setPending(false);
|
||||
settled.current?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setPending(false);
|
||||
setFailure(
|
||||
error instanceof SignageRequestError
|
||||
? error.failure
|
||||
: {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { clearFailure: () => setFailure(undefined), failure, pending, run };
|
||||
}
|
||||
|
||||
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
|
||||
* inventing its own timer. Paused when the interval is zero. */
|
||||
export function useTick(intervalMs: number): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
|
||||
return () => clearInterval(handle);
|
||||
}, [intervalMs]);
|
||||
return tick;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SignageDashboard } from "./SignageDashboard.js";
|
||||
import ownStyles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it
|
||||
* imports into the single browser artifact `entrypoints.ui` declares.
|
||||
*
|
||||
* `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own —
|
||||
* compiled in as text and installed by the host into this artifact's isolated root at mount
|
||||
* (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root
|
||||
* rather than bundled (ADR 0039).
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.0",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<SignageDashboard context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
Field,
|
||||
Panel,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Channel groups, audience channels, and the display roster.
|
||||
*
|
||||
* A church starts with one group and one default channel. `in-person` and `online` are created here,
|
||||
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
|
||||
* understand and ignore (ADR 0004).
|
||||
*/
|
||||
export function DisplaysPanel({
|
||||
api,
|
||||
canManage,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const viewers = useResource(
|
||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||
[],
|
||||
);
|
||||
const edit = useAction(() => {
|
||||
groups.reload();
|
||||
viewers.reload();
|
||||
});
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
||||
groupId: "",
|
||||
key: "",
|
||||
name: "",
|
||||
});
|
||||
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
|
||||
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
|
||||
useTick(5000);
|
||||
const nowMs = Date.now();
|
||||
|
||||
const channels =
|
||||
groups.state.status === "ready"
|
||||
? groups.state.value.groups.flatMap((group) =>
|
||||
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form">
|
||||
<Field label="New channel group">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
placeholder="Nursery hallway"
|
||||
value={groupName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={edit.pending || groupName.trim().length === 0}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", "channel-groups", { name: groupName.trim() });
|
||||
setGroupName("");
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="A group is one clock. An audience channel is one rendering of it."
|
||||
title="Channel groups"
|
||||
>
|
||||
{renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<div className="signage-groups" data-testid="signage-groups">
|
||||
{value.groups.map((group) => (
|
||||
<div className="signage-group" key={group.id}>
|
||||
<div className="signage-group__header">
|
||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
</div>
|
||||
<ul className="ek-list">
|
||||
{group.channels.map((channel) => (
|
||||
<li className="ek-list__item signage-row" key={channel.id}>
|
||||
<span className="signage-row__name">{channel.name}</span>
|
||||
<Badge tone={channel.isDefault ? "success" : "neutral"}>
|
||||
{channel.key}
|
||||
</Badge>
|
||||
{canManage && !channel.isDefault ? (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : undefined}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canManage ? (
|
||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
||||
<Field hint="Lowercase, e.g. online" label="Audience key">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
groupId: group.id,
|
||||
key: event.target.value,
|
||||
name: channelDraft.name,
|
||||
})
|
||||
}
|
||||
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Channel name">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
groupId: group.id,
|
||||
key: channelDraft.key,
|
||||
name: event.target.value,
|
||||
})
|
||||
}
|
||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={
|
||||
edit.pending ||
|
||||
channelDraft.groupId !== group.id ||
|
||||
channelDraft.key.trim() === "" ||
|
||||
channelDraft.name.trim() === ""
|
||||
}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
||||
key: channelDraft.key.trim(),
|
||||
name: channelDraft.name.trim(),
|
||||
});
|
||||
setChannelDraft({ groupId: "", key: "", name: "" });
|
||||
})
|
||||
}
|
||||
>
|
||||
Add audience channel
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading channel groups…", onRetry: groups.reload },
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
description="Every screen Signage has seen, and what it last reported."
|
||||
title="Displays"
|
||||
>
|
||||
{renderLoadable(
|
||||
viewers.state,
|
||||
(value) =>
|
||||
value.viewers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Open a Signage display on a screen and it will register itself here."
|
||||
title="No displays yet"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Display health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Name</TableCell>
|
||||
<TableCell header>State</TableCell>
|
||||
<TableCell header>Channel</TableCell>
|
||||
<TableCell header>Last seen</TableCell>
|
||||
<TableCell header>Offline ready</TableCell>
|
||||
<TableCell header>Most recent error</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.viewers.map((viewer) => (
|
||||
<TableRow key={viewer.id}>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<TextInput
|
||||
aria-label={`Name for ${viewer.name}`}
|
||||
defaultValue={viewer.name}
|
||||
onBlur={(event) =>
|
||||
event.target.value !== viewer.name &&
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
name: event.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
viewer.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OnlineBadge online={viewer.online} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<Select
|
||||
aria-label={`Channel for ${viewer.name}`}
|
||||
onChange={(event) =>
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
channelId: event.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
value={viewer.channelId ?? ""}
|
||||
>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{`${channel.groupName} · ${channel.name}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{viewer.lastError === null ? (
|
||||
"—"
|
||||
) : (
|
||||
<span title={viewer.lastError.detail ?? undefined}>
|
||||
{viewer.lastError.code}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
||||
)}
|
||||
<FailureNote failure={edit.failure} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
|
||||
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createUploadQueue,
|
||||
type UploadFile,
|
||||
type UploadItem,
|
||||
type UploadQueue,
|
||||
} from "../../browser/uploads.js";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource } from "../hooks.js";
|
||||
import type { ImageRecord, SettingsResponse } from "../types.js";
|
||||
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* The native image library and its upload queue.
|
||||
*
|
||||
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
|
||||
* individually bounded requests, a few at a time. The page stays responsive because only a few are
|
||||
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
|
||||
* the wrong type before a byte is sent, while the server still checks both again.
|
||||
*/
|
||||
export function ImagesPanel({
|
||||
api,
|
||||
settings,
|
||||
canManage,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canManage: boolean;
|
||||
}): ReactElement {
|
||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
||||
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
||||
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
||||
const rename = useAction(() => {
|
||||
setRenaming(undefined);
|
||||
images.reload();
|
||||
});
|
||||
const remove = useAction(() => images.reload());
|
||||
|
||||
const queue = useMemo(() => {
|
||||
const created = createUploadQueue({
|
||||
concurrency: settings.uploads.concurrency,
|
||||
maxBytes: settings.uploads.maxBytes,
|
||||
maxFiles: settings.uploads.batchMaxFiles,
|
||||
onChange: (items) => setUploads(items),
|
||||
upload: async (file) => {
|
||||
// One image per request, with the label in the query string so the body is exactly the
|
||||
// bytes. The server streams and counts them; nothing here buffers a second copy.
|
||||
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
|
||||
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
|
||||
},
|
||||
});
|
||||
queueRef.current = created;
|
||||
return created;
|
||||
}, [
|
||||
api,
|
||||
settings.uploads.batchMaxFiles,
|
||||
settings.uploads.concurrency,
|
||||
settings.uploads.maxBytes,
|
||||
]);
|
||||
|
||||
const onFiles = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
queue.enqueue(files);
|
||||
void queue.drain().then(() => images.reload());
|
||||
event.target.value = "";
|
||||
},
|
||||
[images, queue],
|
||||
);
|
||||
|
||||
const finished = uploads.filter(
|
||||
(item) => item.status !== "queued" && item.status !== "uploading",
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<label className="signage-upload-trigger">
|
||||
<span className="ek-button ek-button--primary">Add images</span>
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="signage-upload-input"
|
||||
data-testid="signage-upload-input"
|
||||
multiple
|
||||
onChange={onFiles}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
|
||||
title="Images"
|
||||
>
|
||||
{uploads.length === 0 ? undefined : (
|
||||
<div className="signage-uploads" data-testid="signage-uploads">
|
||||
<ul className="ek-list">
|
||||
{uploads.map((item) => (
|
||||
<li className="ek-list__item signage-row" key={item.id}>
|
||||
<span className="signage-row__name">{item.name}</span>
|
||||
<Badge
|
||||
tone={
|
||||
item.status === "done"
|
||||
? "success"
|
||||
: item.status === "failed" || item.status === "rejected"
|
||||
? "danger"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
{item.message === undefined ? undefined : (
|
||||
<span className="signage-uploads__message">{item.message}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{finished.length === 0 ? undefined : (
|
||||
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderLoadable(
|
||||
images.state,
|
||||
(value) =>
|
||||
value.images.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
|
||||
title="No images yet"
|
||||
/>
|
||||
) : (
|
||||
<ul className="signage-image-grid" data-testid="signage-image-grid">
|
||||
{value.images.map((image) => {
|
||||
const thumbnail = image.renditions.find(
|
||||
(rendition) => rendition.kind === "thumbnail",
|
||||
);
|
||||
return (
|
||||
<li className="signage-image" key={image.id}>
|
||||
{thumbnail === undefined ? (
|
||||
<div className="signage-image__missing">No preview</div>
|
||||
) : (
|
||||
<img
|
||||
alt={image.displayName}
|
||||
className="signage-image__thumbnail"
|
||||
height={thumbnail.height}
|
||||
src={thumbnail.url}
|
||||
width={thumbnail.width}
|
||||
/>
|
||||
)}
|
||||
<div className="signage-image__meta">
|
||||
{renaming?.id === image.id ? (
|
||||
<Field label="Name">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setRenaming({ id: image.id, value: event.target.value })
|
||||
}
|
||||
value={renaming.value}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<p className="signage-image__name">{image.displayName}</p>
|
||||
)}
|
||||
<p className="signage-image__detail">
|
||||
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
|
||||
</p>
|
||||
{image.expiresAt === null ? undefined : (
|
||||
<Badge className="signage-image__expiry" tone="warning">
|
||||
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="signage-image__actions">
|
||||
{renaming?.id === image.id ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={rename.pending}
|
||||
onClick={() =>
|
||||
rename.run(() =>
|
||||
api.send("PATCH", `images/${image.id}`, {
|
||||
displayName: renaming.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={remove.pending}
|
||||
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
|
||||
variant="danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
{ label: "Loading images…", onRetry: images.reload },
|
||||
)}
|
||||
<FailureNote failure={rename.failure ?? remove.failure} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type {
|
||||
ChannelGroupView,
|
||||
PlaybackDetailView,
|
||||
SettingsResponse,
|
||||
TimelineSummary,
|
||||
} from "../types.js";
|
||||
import { PreviewPanel } from "./PreviewPanel.js";
|
||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Live transport for one channel group.
|
||||
*
|
||||
* Every control here is the same operation the `signage.playback` capability exposes, over the same
|
||||
* serialized service — a volunteer with a phone and an operator at this screen are pressing the same
|
||||
* button, and neither can produce a state the other did not ask for.
|
||||
*/
|
||||
export function PlaybackPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
canControl,
|
||||
groupId,
|
||||
onGroupChange,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canControl: boolean;
|
||||
readonly groupId: string | undefined;
|
||||
readonly onGroupChange: (groupId: string) => void;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const detail = useResource(
|
||||
() =>
|
||||
groupId === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||
[groupId],
|
||||
);
|
||||
const command = useAction(() => {
|
||||
detail.reload();
|
||||
groups.reload();
|
||||
});
|
||||
const [activateId, setActivateId] = useState("");
|
||||
const previewChannelId =
|
||||
groups.state.status === "ready"
|
||||
? (groups.state.value.groups
|
||||
.find((group) => group.id === groupId)
|
||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||
: undefined;
|
||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||
useTick(1000);
|
||||
|
||||
function run(path: string, body?: unknown): void {
|
||||
if (groupId === undefined) {
|
||||
return;
|
||||
}
|
||||
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<Field label="Channel group">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => onGroupChange(event.target.value)}
|
||||
value={groupId ?? ""}
|
||||
>
|
||||
{value.groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
),
|
||||
{ label: "Loading groups…" },
|
||||
)}
|
||||
description="One clock for every channel in the group."
|
||||
title="Playback"
|
||||
>
|
||||
{groupId === undefined ? (
|
||||
<EmptyState title="No channel group selected" />
|
||||
) : (
|
||||
renderLoadable(
|
||||
detail.state,
|
||||
(value) =>
|
||||
value === undefined ? (
|
||||
<EmptyState title="No channel group selected" />
|
||||
) : (
|
||||
<div className="signage-playback" data-testid="signage-playback">
|
||||
<div className="signage-playback__status">
|
||||
<Badge
|
||||
tone={
|
||||
value.snapshot.transport === "playing"
|
||||
? "success"
|
||||
: value.snapshot.transport === "idle"
|
||||
? "warning"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{value.snapshot.transport}
|
||||
</Badge>
|
||||
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
||||
{value.current?.stale === true ? (
|
||||
<Badge tone="warning">Stale</Badge>
|
||||
) : undefined}
|
||||
{value.current?.withdrawn === true ? (
|
||||
<Badge tone="warning">Withdrawn</Badge>
|
||||
) : undefined}
|
||||
{value.snapshot.pendingTimelineId === null ? undefined : (
|
||||
<Badge tone="info">Activation queued</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.current === null ? (
|
||||
<EmptyState
|
||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||
title="Nothing on screen"
|
||||
/>
|
||||
) : (
|
||||
<dl className="signage-playback__current" data-testid="signage-current">
|
||||
<dt>Provider</dt>
|
||||
<dd>{value.current.providerId}</dd>
|
||||
<dt>Item</dt>
|
||||
<dd>{value.current.itemId}</dd>
|
||||
<dt>Renderer</dt>
|
||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||
<dt>Remaining</dt>
|
||||
<dd data-testid="signage-remaining">
|
||||
{value.snapshot.remainingMs === null
|
||||
? "—"
|
||||
: formatDuration(value.snapshot.remainingMs)}
|
||||
</dd>
|
||||
<dt>Started</dt>
|
||||
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__controls" data-testid="signage-controls">
|
||||
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||
Previous
|
||||
</Button>
|
||||
{value.snapshot.transport === "paused" ? (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("resume")}
|
||||
variant="primary"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||
>
|
||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||
</Button>
|
||||
{value.snapshot.staticAssetId === null ? undefined : (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("static", { assetId: null })}
|
||||
>
|
||||
Release hold
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={command.pending} onClick={() => run("reload")}>
|
||||
Reload content
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__activate">
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(loaded) => (
|
||||
<>
|
||||
<Field label="Activate timeline">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => setActivateId(event.target.value)}
|
||||
value={activateId}
|
||||
>
|
||||
<option value="">Choose a timeline…</option>
|
||||
{loaded.timelines.map((timeline) => (
|
||||
<option key={timeline.id} value={timeline.id}>
|
||||
{timeline.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={command.pending || activateId === ""}
|
||||
onClick={() =>
|
||||
run("activate", { timelineId: activateId, when: "now" })
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Now
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending || activateId === ""}
|
||||
onClick={() =>
|
||||
run("activate", { timelineId: activateId, when: "next-boundary" })
|
||||
}
|
||||
>
|
||||
At next slot
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
{ label: "Loading timelines…" },
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{value.history.length === 0 ? undefined : (
|
||||
<div className="signage-playback__history">
|
||||
<h4 className="signage-editor__subtitle">Recently played</h4>
|
||||
<ul className="ek-list">
|
||||
{value.history.slice(0, 8).map((entry) => (
|
||||
<li className="ek-list__item signage-row" key={entry.id}>
|
||||
<span className="signage-row__name">{entry.itemId}</span>
|
||||
<span className="signage-playback__history-meta">
|
||||
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading playback…", onRetry: detail.reload },
|
||||
)
|
||||
)}
|
||||
<FailureNote failure={command.failure} />
|
||||
</Panel>
|
||||
|
||||
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EmptyState, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
import { SignageSlate } from "../../browser/slate.js";
|
||||
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
||||
import type { SignageApi } from "../api.js";
|
||||
|
||||
/**
|
||||
* The live preview.
|
||||
*
|
||||
* It mounts **the display controller itself** — the same scene stage, the same native-image
|
||||
* presentation resolver, the same transition, the same renderer host — against the same channel
|
||||
* state a wall reads. A preview that reimplemented any of that would drift from what the room
|
||||
* actually sees, which makes it worse than no preview at all.
|
||||
*
|
||||
* It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no
|
||||
* viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports
|
||||
* health for a screen that does not exist. It drives its own refresh instead of subscribing.
|
||||
*/
|
||||
export function PreviewPanel({
|
||||
api,
|
||||
channelId,
|
||||
context,
|
||||
refreshMs = 2000,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly channelId: string | undefined;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly refreshMs?: number;
|
||||
}): ReactElement {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = stageRef.current;
|
||||
if (!root || channelId === undefined) {
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const controller = createDisplayController({ context, root, telemetry: false });
|
||||
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function pump(): Promise<void> {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await api.get<DisplayChannelStateView>(`channels/${channelId}/display-state`);
|
||||
if (!stopped) {
|
||||
await controller.apply(state);
|
||||
}
|
||||
} catch {
|
||||
// A preview that cannot reach the server simply keeps its last frame, exactly as a wall
|
||||
// does. Nothing about a failed poll belongs in the operator's face.
|
||||
}
|
||||
if (!stopped) {
|
||||
handle = setTimeout(() => void pump(), refreshMs);
|
||||
}
|
||||
}
|
||||
|
||||
void controller.start().then(() => pump());
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (handle !== undefined) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
void controller.stop();
|
||||
};
|
||||
}, [api, channelId, context, refreshMs]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
description="Exactly what a display on this channel is showing, rendered by the display's own code."
|
||||
title="Live preview"
|
||||
>
|
||||
{channelId === undefined ? (
|
||||
<EmptyState title="No channel to preview" />
|
||||
) : (
|
||||
<div className="signage-preview">
|
||||
<div className="signage-preview__frame">
|
||||
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
|
||||
<div className="signage-slate-host">
|
||||
<SignageSlate />
|
||||
</div>
|
||||
<div aria-hidden="true" className="signage-blackout" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
Badge,
|
||||
EmptyState,
|
||||
Panel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@ekklesia/ui";
|
||||
import type { ReactElement } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useResource } from "../hooks.js";
|
||||
import type { ProviderHealth, SettingsResponse } from "../types.js";
|
||||
import { formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
|
||||
|
||||
/**
|
||||
* Provider health, and the one thing only a browser can tell an operator.
|
||||
*
|
||||
* A registration is a **running fact**: a provider that was uninstalled or disabled simply is not
|
||||
* here, and there is no stored row claiming otherwise. Everything shown is either the provider's own
|
||||
* declared identity or a count Signage kept — never a provider's exception, message, or payload.
|
||||
*
|
||||
* Renderer availability is checked here rather than on the server because the server genuinely
|
||||
* cannot know it: whether the platform published a module's contribution is a fact the *host* holds,
|
||||
* and `context.contributions` is where it is legible. A provider whose renderer was not published —
|
||||
* because the module is missing, disabled, or outside Signage's compatible range — is reported as
|
||||
* exactly that, which is the difference between "this is broken" and "this needs an update".
|
||||
*/
|
||||
export function ProvidersPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
}): ReactElement {
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const published = new Set(
|
||||
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
|
||||
title="Content providers"
|
||||
>
|
||||
{renderLoadable(
|
||||
providers.state,
|
||||
(value) =>
|
||||
value.providers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Signage works on its own with native images. Install a content module to add more."
|
||||
title="No content providers registered"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Provider health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Provider</TableCell>
|
||||
<TableCell header>Contract</TableCell>
|
||||
<TableCell header>Renderer</TableCell>
|
||||
<TableCell header>Health</TableCell>
|
||||
<TableCell header>Last success</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.providers.map((provider) => {
|
||||
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||
return (
|
||||
<TableRow key={provider.providerId}>
|
||||
<TableCell>
|
||||
<span className="signage-provider__id">{provider.providerId}</span>
|
||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||
</TableCell>
|
||||
<TableCell>{provider.contractVersion}</TableCell>
|
||||
<TableCell>
|
||||
{isNative ? (
|
||||
<Badge tone="success">Signage renders this</Badge>
|
||||
) : rendererPublished ? (
|
||||
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||
) : (
|
||||
<Badge tone="warning">Renderer not published</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.consecutiveFailures === 0 ? (
|
||||
<Badge tone="success">Healthy</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">
|
||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading providers…", onRetry: providers.reload },
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Field,
|
||||
Panel,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource } from "../hooks.js";
|
||||
import type {
|
||||
ImageRecord,
|
||||
ProviderHealth,
|
||||
ProviderItemDescriptor,
|
||||
ProviderSourceDescriptor,
|
||||
SettingsResponse,
|
||||
TimelineDetail,
|
||||
TimelineSummary,
|
||||
} from "../types.js";
|
||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
const NATIVE_PROVIDER_ID = "signage.native-images";
|
||||
|
||||
/**
|
||||
* The timeline editor.
|
||||
*
|
||||
* The pattern an operator arranges here is the product: an ordered list of slots, each either a
|
||||
* provider source that asks for the next item or one pinned item. Repeating a source is how a loop
|
||||
* gets three announcements in a row — the five occurrences share one rotation, and this screen makes
|
||||
* that visible by naming the source on every row rather than hiding it.
|
||||
*/
|
||||
export function TimelinesPanel({
|
||||
api,
|
||||
settings,
|
||||
canManage,
|
||||
onActivate,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canManage: boolean;
|
||||
readonly onActivate: ((timelineId: string) => void) | undefined;
|
||||
}): ReactElement {
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||
const [newName, setNewName] = useState("");
|
||||
const create = useAction(() => {
|
||||
setNewName("");
|
||||
timelines.reload();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form">
|
||||
<Field label="New timeline">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="Sunday morning"
|
||||
value={newName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={create.pending || newName.trim().length === 0}
|
||||
onClick={() =>
|
||||
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="Saved patterns an operator builds on demand. Signage ships none."
|
||||
title="Timelines"
|
||||
>
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(value) =>
|
||||
value.timelines.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Create one and add slots to it. There are no presets, on purpose."
|
||||
title="No timelines yet"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Saved timelines">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Name</TableCell>
|
||||
<TableCell header>Eligible</TableCell>
|
||||
<TableCell header>Revision</TableCell>
|
||||
<TableCell header>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.timelines.map((timeline) => (
|
||||
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
|
||||
<TableCell>
|
||||
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
|
||||
? "Always"
|
||||
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
||||
</TableCell>
|
||||
<TableCell>{timeline.revision}</TableCell>
|
||||
<TableCell>
|
||||
{onActivate === undefined ? undefined : (
|
||||
<Button onClick={() => onActivate(timeline.id)} variant="primary">
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading timelines…", onRetry: timelines.reload },
|
||||
)}
|
||||
<FailureNote failure={create.failure} />
|
||||
</Panel>
|
||||
|
||||
{selectedId === undefined ? undefined : (
|
||||
<TimelineEditor
|
||||
api={api}
|
||||
canManage={canManage}
|
||||
key={selectedId}
|
||||
onChanged={timelines.reload}
|
||||
onClose={() => setSelectedId(undefined)}
|
||||
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
|
||||
settings={settings}
|
||||
timelineId={selectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEditor({
|
||||
api,
|
||||
canManage,
|
||||
onChanged,
|
||||
onClose,
|
||||
providers,
|
||||
settings,
|
||||
timelineId,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
readonly onChanged: () => void;
|
||||
readonly onClose: () => void;
|
||||
readonly providers: readonly ProviderHealth[];
|
||||
readonly settings: SettingsResponse;
|
||||
readonly timelineId: string;
|
||||
}): ReactElement {
|
||||
const detail = useResource(
|
||||
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
||||
[timelineId],
|
||||
);
|
||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||
const edit = useAction(() => {
|
||||
detail.reload();
|
||||
onChanged();
|
||||
});
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
|
||||
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
||||
|
||||
function loadProvider(nextProviderId: string): void {
|
||||
setProviderId(nextProviderId);
|
||||
setSources([]);
|
||||
setItems([]);
|
||||
if (nextProviderId === "") {
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
|
||||
`providers/${encodeURIComponent(nextProviderId)}/sources`,
|
||||
)
|
||||
.then((response) => setSources(response.sources))
|
||||
.catch(() => setSources([]));
|
||||
void api
|
||||
.get<{ items: readonly ProviderItemDescriptor[] }>(
|
||||
`providers/${encodeURIComponent(nextProviderId)}/items`,
|
||||
)
|
||||
.then((response) => setItems(response.items))
|
||||
.catch(() => setItems([]));
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
actions={<Button onClick={onClose}>Close</Button>}
|
||||
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
|
||||
title="Timeline pattern"
|
||||
>
|
||||
{renderLoadable(
|
||||
detail.state,
|
||||
(value) => (
|
||||
<div className="signage-editor" data-testid="signage-timeline-editor">
|
||||
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
||||
|
||||
{canManage ? (
|
||||
<div className="signage-editor__add" data-testid="signage-add-source">
|
||||
<Field
|
||||
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
||||
label="Content provider"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => loadProvider(event.target.value)}
|
||||
value={providerId}
|
||||
>
|
||||
<option value="">Choose a provider…</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.providerId} value={provider.providerId}>
|
||||
{provider.providerId}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
{sources.map((source) => (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
key={source.id}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/sources`, {
|
||||
label: source.label,
|
||||
providerId,
|
||||
sourceId: source.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`Add source: ${source.label}`}
|
||||
</Button>
|
||||
))}
|
||||
{items.slice(0, 20).map((item) => (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||
itemId: item.id,
|
||||
providerId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`Pin item: ${item.label}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{value.sources.length === 0 ? undefined : (
|
||||
<div className="signage-editor__sources">
|
||||
<h4 className="signage-editor__subtitle">Configured sources</h4>
|
||||
{value.sources.map((source) => (
|
||||
<div className="signage-editor__source" key={source.id}>
|
||||
<span>{source.label}</span>
|
||||
<Badge tone="info">{source.providerId}</Badge>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||
sourceId: source.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Add slot
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.slots.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
|
||||
title="This timeline has no slots"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Timeline slots">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>#</TableCell>
|
||||
<TableCell header>Content</TableCell>
|
||||
<TableCell header>Duration</TableCell>
|
||||
<TableCell header>Enabled</TableCell>
|
||||
<TableCell header>Order</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.slots.map((slot, index) => {
|
||||
const source = value.sources.find(
|
||||
(candidate) => candidate.id === slot.sourceId,
|
||||
);
|
||||
const image =
|
||||
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
|
||||
? images.state.value.images.find(
|
||||
(candidate) => candidate.id === slot.itemId,
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow key={slot.id}>
|
||||
<TableCell>{slot.position + 1}</TableCell>
|
||||
<TableCell>
|
||||
{slot.kind === "provider-source"
|
||||
? (source?.label ?? "Removed source")
|
||||
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{slot.durationOverrideMs === null
|
||||
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
|
||||
: formatDuration(slot.durationOverrideMs)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={slot.enabled}
|
||||
disabled={!canManage || edit.pending}
|
||||
label={slot.enabled ? "On" : "Off"}
|
||||
onChange={(event) =>
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
|
||||
enabled: event.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<div className="signage-editor__order">
|
||||
<Button
|
||||
disabled={index === 0 || edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||
slotIds: swap(
|
||||
value.slots.map((candidate) => candidate.id),
|
||||
index,
|
||||
index - 1,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
disabled={index === value.slots.length - 1 || edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||
slotIds: swap(
|
||||
value.slots.map((candidate) => candidate.id),
|
||||
index,
|
||||
index + 1,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Down
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="signage-editor__footer">
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/duplicate`, {
|
||||
name: `${value.timeline.name} (copy)`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("DELETE", `timelines/${timelineId}`);
|
||||
onClose();
|
||||
})
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Delete timeline
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading timeline…", onRetry: detail.reload },
|
||||
)}
|
||||
<FailureNote failure={edit.failure} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function swap(ids: readonly string[], from: number, to: number): string[] {
|
||||
const next = [...ids];
|
||||
const moved = next[from];
|
||||
const target = next[to];
|
||||
if (moved === undefined || target === undefined) {
|
||||
return next;
|
||||
}
|
||||
next[from] = target;
|
||||
next[to] = moved;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { SignageApiFailure } from "../api.js";
|
||||
import type { Loadable } from "../hooks.js";
|
||||
|
||||
/**
|
||||
* The one place a panel's loading, failure, and ready states are turned into elements.
|
||||
*
|
||||
* Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the
|
||||
* failure text is always Signage's own sanitized message, never a caught exception.
|
||||
*/
|
||||
export function renderLoadable<Value>(
|
||||
state: Loadable<Value>,
|
||||
render: (value: Value) => ReactNode,
|
||||
options: { readonly label: string; readonly onRetry?: () => void },
|
||||
): ReactNode {
|
||||
if (state.status === "loading") {
|
||||
return <LoadingState label={options.label} />;
|
||||
}
|
||||
if (state.status === "failed") {
|
||||
return (
|
||||
<ErrorState
|
||||
action={
|
||||
options.onRetry === undefined ? undefined : (
|
||||
<button
|
||||
className="ek-button ek-button--secondary"
|
||||
onClick={options.onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)
|
||||
}
|
||||
description={state.failure.message}
|
||||
title="Signage could not load this"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return render(state.value);
|
||||
}
|
||||
|
||||
/** A refused save, shown where the operator was working. The server's own sanitized message and
|
||||
* nothing else: no status line, no URL, no request detail. */
|
||||
export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) {
|
||||
if (!failure) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<p className="signage-failure" role="alert">
|
||||
{failure.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1000;
|
||||
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatInstant(value: string | number | null, timezone: string): string {
|
||||
if (value === null) {
|
||||
return "—";
|
||||
}
|
||||
const date = typeof value === "number" ? new Date(value) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "—";
|
||||
}
|
||||
// Always in the installation's own zone, and always labelled with it. A church reading "9:00"
|
||||
// needs to know which nine o'clock, and a browser's local zone is not the answer.
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: timezone,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatSince(atMs: number | null, nowMs: number): string {
|
||||
if (atMs === null) {
|
||||
return "never";
|
||||
}
|
||||
const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
||||
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
* The Signage management UI's own stylesheet.
|
||||
*
|
||||
* Compiled into the declared browser artifact as text and installed by the host into this artifact's
|
||||
* isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing
|
||||
* outside Signage reaches in.
|
||||
*
|
||||
* Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from
|
||||
* one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and
|
||||
* the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's
|
||||
* output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content
|
||||
* belongs to whoever made it.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The box model for the tree Signage owns.
|
||||
*
|
||||
* An isolated root inherits nothing from the page around it, and the platform's stylesheet declares
|
||||
* no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`,
|
||||
* is laid out wider than the field containing it by its own padding and border, and slides under
|
||||
* whatever sits beside it. Setting the box model once here fixes that everywhere, rather than
|
||||
* subtracting padding by hand in each panel.
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--signage-gap: var(--ekklesia-spacing-lg);
|
||||
--signage-gap-tight: var(--ekklesia-spacing-sm);
|
||||
--signage-panel-radius: var(--ekklesia-radius-lg);
|
||||
--signage-thumbnail-width: 220px;
|
||||
}
|
||||
|
||||
.signage-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signage-narrow-notice {
|
||||
background: var(--ekklesia-background-surface);
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-base);
|
||||
border-radius: var(--signage-panel-radius);
|
||||
color: var(--ekklesia-text-primary);
|
||||
display: grid;
|
||||
font-family: var(--ekklesia-typography-family-sans);
|
||||
gap: var(--signage-gap-tight);
|
||||
padding: var(--ekklesia-spacing-xl);
|
||||
}
|
||||
|
||||
.signage-narrow-notice__title {
|
||||
font-size: var(--ekklesia-typography-size-heading-3);
|
||||
font-weight: var(--ekklesia-typography-weight-bold);
|
||||
line-height: var(--ekklesia-typography-line-height-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-narrow-notice__body {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
line-height: var(--ekklesia-typography-line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.signage-narrow-notice {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signage-desktop {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.signage-columns {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-inline-form {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
/*
|
||||
* A labelled control beside the button that submits it. The field takes the width its control asks
|
||||
* for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding
|
||||
* the button on top of it.
|
||||
*/
|
||||
.signage-inline-form > .ek-field {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signage-inline-form .ek-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* On one line, a field's hint hangs below the row rather than making that one field taller. The row
|
||||
* is bottom-aligned so the controls share a line with the button, and a hint left in flow would push
|
||||
* its own control above the others.
|
||||
*/
|
||||
.signage-inline-form > .ek-field > .ek-field__hint {
|
||||
inset-block-start: 100%;
|
||||
inset-inline-start: 0;
|
||||
padding-block-start: var(--ekklesia-spacing-2xs);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-inline-form:has(.ek-field__hint) {
|
||||
padding-block-end: var(--ekklesia-spacing-lg);
|
||||
}
|
||||
|
||||
.signage-failure {
|
||||
color: var(--ekklesia-text-danger);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
margin: var(--signage-gap-tight) 0 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A list row that carries a name beside its metadata. The platform's list item is a plain block, so
|
||||
* without this the two run together into one unreadable word — and the row's own children already
|
||||
* assume a flex line.
|
||||
*/
|
||||
.signage-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
.signage-row__name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Images.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-upload-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.signage-upload-input {
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.signage-uploads {
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
margin-bottom: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-uploads__message {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
.signage-image-grid {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signage-image {
|
||||
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
|
||||
than spread between the thumbnail and the name. */
|
||||
align-content: start;
|
||||
background: var(--ekklesia-background-canvas);
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
overflow: hidden;
|
||||
padding: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
|
||||
a tall image is letterboxed here rather than reflowing the card it sits in. */
|
||||
.signage-image__thumbnail {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--ekklesia-background-inset);
|
||||
border-radius: var(--ekklesia-radius-sm);
|
||||
display: block;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-image__missing {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--ekklesia-background-inset);
|
||||
border-radius: var(--ekklesia-radius-sm);
|
||||
color: var(--ekklesia-text-muted);
|
||||
display: flex;
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signage-image__meta {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* A badge is sized by what it says, not by the column it lands in. */
|
||||
.signage-image__expiry {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.signage-image__name {
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signage-image__detail {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-image__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Timelines.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-editor {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-editor__title {
|
||||
font-size: var(--ekklesia-typography-size-heading-3);
|
||||
font-weight: var(--ekklesia-typography-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-editor__subtitle {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.signage-editor__add,
|
||||
.signage-editor__sources,
|
||||
.signage-editor__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-editor__sources {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
}
|
||||
|
||||
.signage-editor__source {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-editor__order {
|
||||
display: flex;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Playback.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-playback {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-playback__status,
|
||||
.signage-playback__controls,
|
||||
.signage-playback__activate {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-playback__current {
|
||||
column-gap: var(--signage-gap);
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
margin: 0;
|
||||
row-gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
.signage-playback__current dt {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
.signage-playback__current dd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-playback__history-meta {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Displays and providers.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-groups {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-group {
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
padding: var(--ekklesia-spacing-md);
|
||||
}
|
||||
|
||||
.signage-group__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-provider__id {
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
}
|
||||
|
||||
.signage-provider__version {
|
||||
color: var(--ekklesia-text-muted);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
/* Far enough that the version reads as a second word, not the tail of the identifier. */
|
||||
margin-left: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Live preview.
|
||||
*
|
||||
* The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the
|
||||
* display's own scene layers render exactly as they do on a wall — this panel supplies a size and
|
||||
* nothing else.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-preview {
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-preview__frame {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000000;
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-stage {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transition-duration: 0ms;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: linear;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__background {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__background-image {
|
||||
display: none;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
transform: scale(1.08);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__darken {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__foreground {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__renderer {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-blackout {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 200ms linear;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.signage-preview [data-blackout="true"] > .signage-blackout {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate-host {
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.signage-preview [data-slate="true"] > .signage-slate-host {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bars {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--one {
|
||||
background: #6f6f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--two {
|
||||
background: #6f6f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--three {
|
||||
background: #2f6f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--four {
|
||||
background: #2f6f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--five {
|
||||
background: #6f2f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--six {
|
||||
background: #6f2f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__message {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
flex: 0 0 auto;
|
||||
font-size: 2vh;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
padding: 1.5vh 0;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The wire shapes Signage's dashboard reads.
|
||||
*
|
||||
* Signage owns both ends of every one of these, which is exactly why they are written down: the
|
||||
* server's route and this file are one contract, and a drift between them is a bug an operator sees
|
||||
* rather than a compiler does.
|
||||
*/
|
||||
|
||||
export interface SettingsResponse {
|
||||
readonly crossfadeMs: number;
|
||||
readonly moduleVersion: string;
|
||||
readonly nativeImageDurationMs: number;
|
||||
readonly nativePresentation: {
|
||||
readonly backgroundMode: string;
|
||||
readonly backgroundColor: string;
|
||||
readonly backgroundDarkened: boolean;
|
||||
readonly blurPixels: number;
|
||||
};
|
||||
readonly rendererInputVersion: string;
|
||||
readonly timezone: string;
|
||||
readonly uploads: {
|
||||
readonly batchMaxFiles: number;
|
||||
readonly concurrency: number;
|
||||
readonly maxBytes: number;
|
||||
readonly maxPixels: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageRendition {
|
||||
readonly kind: string;
|
||||
readonly url: string;
|
||||
readonly expiresAt: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly originalFilename: string;
|
||||
readonly contentType: string;
|
||||
readonly byteSize: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly status: string;
|
||||
readonly expiresAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly renditions: readonly ImageRendition[];
|
||||
}
|
||||
|
||||
export interface TimelineSummary {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly eligibleFrom: string | null;
|
||||
readonly eligibleUntil: string | null;
|
||||
readonly revision: number;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TimelineSource {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly providerId: string;
|
||||
readonly sourceId: string;
|
||||
}
|
||||
|
||||
export interface TimelineSlot {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly kind: "provider-source" | "pinned-item";
|
||||
readonly sourceId: string | null;
|
||||
readonly providerId: string | null;
|
||||
readonly itemId: string | null;
|
||||
readonly enabled: boolean;
|
||||
readonly durationOverrideMs: number | null;
|
||||
readonly eligibleFrom: string | null;
|
||||
readonly eligibleUntil: string | null;
|
||||
readonly presentation: {
|
||||
readonly backgroundMode: string | null;
|
||||
readonly backgroundColor: string | null;
|
||||
readonly backgroundDarkened: boolean | null;
|
||||
readonly blurPixels: number | null;
|
||||
readonly foregroundShadow: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineDetail {
|
||||
readonly timeline: TimelineSummary;
|
||||
readonly sources: readonly TimelineSource[];
|
||||
readonly slots: readonly TimelineSlot[];
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
readonly providerId: string;
|
||||
readonly providerVersion: string;
|
||||
readonly contractVersion: string;
|
||||
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
|
||||
readonly defaultDurationMs: number;
|
||||
readonly registeredAt: string;
|
||||
readonly consecutiveFailures: number;
|
||||
readonly lastFailureCode: string | null;
|
||||
readonly lastFailureAt: string | null;
|
||||
readonly lastSuccessAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderSourceDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
export interface ProviderItemDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
readonly availableUntil?: string | null;
|
||||
}
|
||||
|
||||
export type TransportState = "idle" | "playing" | "paused" | "static";
|
||||
|
||||
export interface PlaybackSnapshotView {
|
||||
readonly groupId: string;
|
||||
readonly revision: number;
|
||||
readonly serverNowMs: number;
|
||||
readonly transport: TransportState;
|
||||
readonly blackout: boolean;
|
||||
readonly activeTimelineId: string | null;
|
||||
readonly pendingTimelineId: string | null;
|
||||
readonly scheduledTimelineId: string | null;
|
||||
readonly scheduledActivationAtMs: number | null;
|
||||
readonly currentSelectionId: string | null;
|
||||
readonly currentSlotId: string | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
readonly remainingMs: number | null;
|
||||
readonly selectionRevision: number | null;
|
||||
readonly staticAssetId: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelView {
|
||||
readonly id: string;
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelGroupView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly revision: number;
|
||||
readonly channels: readonly ChannelView[];
|
||||
readonly playback: PlaybackSnapshotView;
|
||||
}
|
||||
|
||||
export interface PlaybackDetailView {
|
||||
readonly snapshot: PlaybackSnapshotView;
|
||||
readonly current: {
|
||||
readonly id: string;
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
readonly providerId: string;
|
||||
readonly rendererContributionId: string | null;
|
||||
readonly revision: number;
|
||||
readonly stale: boolean;
|
||||
readonly withdrawn: boolean;
|
||||
readonly durationMs: number;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
} | null;
|
||||
readonly history: readonly {
|
||||
readonly id: string;
|
||||
readonly itemId: string;
|
||||
readonly providerId: string;
|
||||
readonly sequence: number;
|
||||
readonly startedAtMs: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ViewerHealthView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly online: boolean;
|
||||
readonly channelId: string | null;
|
||||
readonly deviceId: string | null;
|
||||
readonly lastSeenAtMs: number | null;
|
||||
readonly reportedTimelineId: string | null;
|
||||
readonly reportedSelectionId: string | null;
|
||||
readonly reportedProviderId: string | null;
|
||||
readonly reportedContributionId: string | null;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly lastError: {
|
||||
readonly code: string;
|
||||
readonly detail: string | null;
|
||||
readonly atMs: number;
|
||||
} | null;
|
||||
}
|
||||
Reference in New Issue
Block a user