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