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