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", }; }