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; unmount?(): PromiseLike | 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; /** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */ readonly decodeImage?: (image: HTMLImageElement) => Promise; /** 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; /** 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; } 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 { 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 { 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 { 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 { 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 { 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 { 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" }; }, }; }