361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
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" };
|
|
},
|
|
};
|
|
}
|