178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
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",
|
|
};
|
|
}
|