Implement Phase 5A resilient display renderer
Build the real /display Chromium signage client as a React route on the existing single-page build. The server remains authoritative; the display only consumes the verified Phase 3B snapshot and reports telemetry. The renderer owns two permanent scene layers that are never recreated. A transition prepares the hidden layer, decodes the incoming image on the element that will actually be composited, then raises that layer above the current one at opacity 0 and fades it in while the outgoing layer stays fully opaque underneath. This removes the v1 defect where two simultaneous fades let the black root through the composite. Every asynchronous load and transition carries a generation token, so work finishing after authoritative state moved on is discarded rather than shown. A true 16:9 slide fills the frame with no backdrop or shadow; anything else is contained, centred, and uncropped over a blurred, solid, or black background with a subtle shadow. Blur uses the global setting and is overscanned; boolean darkening applies to the background only. Static targets use the same renderer with the snapshot's global presentation defaults, and mode off is a topmost gate while the renderer keeps tracking underneath. Slide bytes live in an explicit Cache Storage cache with reference counted object URLs and conservative pruning, never in ordinary HTTP caching. A new manifest is staged before promotion, so one unfetchable asset marks only itself unavailable instead of rejecting a playlist. A small IndexedDB store with explicit version handling holds the persistent client ID, last known good manifest, timing anchor, and last structured error, and discards malformed records on read. A narrow service worker caches only the /display app shell, network-first for navigations, and explicitly bypasses media, API, and socket traffic. The display sends a real display:heartbeat every 12 seconds with its actual state, and reports structured errors as bounded JSON that the server's existing string validation already accepts; /api/displays parses them back into an object, so no migration was required. Verified against an isolated stack through Nginx with 20 Playwright Chromium tests plus 9 client unit tests, and 187/187 backend tests. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+10
-71
@@ -1,74 +1,13 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
background: red !important;
|
||||
.placeholder {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
margin: 0 auto;
|
||||
max-width: 40rem;
|
||||
padding: 3rem 1.5rem;
|
||||
color: #1f2933;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
.placeholder code {
|
||||
background: #eef1f5;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
}
|
||||
|
||||
.slideshow {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.previous {
|
||||
opacity: 1;
|
||||
animation: fadeOut 1000ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.current {
|
||||
opacity: 0;
|
||||
animation: fadeIn 1000ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: black;
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blank-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: black;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
+8
-63
@@ -1,67 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import "./App.css";
|
||||
|
||||
const socket = io();
|
||||
|
||||
// Placeholder management route. The dashboard is a later phase; the signage
|
||||
// output lives at /display.
|
||||
export default function App() {
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [previousSlide, setPreviousSlide] = useState(null);
|
||||
const [mode, setMode] = useState("on");
|
||||
|
||||
useEffect(() => {
|
||||
socket.on("slideshow:state", (state) => {
|
||||
setSlides(state.slides || []);
|
||||
setMode(state.mode ?? "on");
|
||||
|
||||
setCurrentSlide((oldSlide) => {
|
||||
if (oldSlide !== state.currentSlide) {
|
||||
setPreviousSlide(oldSlide);
|
||||
}
|
||||
|
||||
return state.currentSlide;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setPreviousSlide(null);
|
||||
}, 1100);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off("slideshow:state");
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (mode === "off") {
|
||||
return <div className="blank-screen" />;
|
||||
}
|
||||
|
||||
const currentImage = slides[currentSlide];
|
||||
const previousImage =
|
||||
previousSlide !== null ? slides[previousSlide] : null;
|
||||
|
||||
if (!currentImage) {
|
||||
return <div className="empty">No slides loaded</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="slideshow">
|
||||
{previousImage && (
|
||||
<img
|
||||
key={`previous-${previousImage}`}
|
||||
className="slide previous"
|
||||
src={previousImage}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
|
||||
<img
|
||||
key={`current-${currentImage}`}
|
||||
className="slide current"
|
||||
src={currentImage}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<main className="placeholder">
|
||||
<h1>Announcements</h1>
|
||||
<p>
|
||||
The signage output is served at <code>/display</code>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { createAssetManager } from "./assetCache.js";
|
||||
import { createDisplayController } from "./displayController.js";
|
||||
import {
|
||||
createDisplayPersistence,
|
||||
createDisplayStore,
|
||||
openDisplayDatabase,
|
||||
} from "./displayDb.js";
|
||||
import { createSceneLayer } from "./sceneLayer.js";
|
||||
import "./display.css";
|
||||
|
||||
function createClientId() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||||
return `display-${Math.random().toString(16).slice(2)}-${Date.now()}`;
|
||||
}
|
||||
|
||||
// React renders this skeleton exactly once. The controller owns the pixels, so
|
||||
// no scene element is ever recreated by a re-render.
|
||||
function SceneMarkup({ layerRef, index }) {
|
||||
return (
|
||||
<div className="scene" data-layer={index} ref={layerRef}>
|
||||
<div className="scene-background">
|
||||
<img className="scene-background-image" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="scene-darken" />
|
||||
<img className="scene-foreground" alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DisplayApp() {
|
||||
const layerZeroRef = useRef(null);
|
||||
const layerOneRef = useRef(null);
|
||||
const gateRef = useRef(null);
|
||||
const controllerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let controller = null;
|
||||
let database = null;
|
||||
|
||||
async function boot() {
|
||||
document.body.dataset.displayRoute = "true";
|
||||
|
||||
let persistence = null;
|
||||
let clientId;
|
||||
try {
|
||||
database = await openDisplayDatabase();
|
||||
const store = createDisplayStore({ database });
|
||||
persistence = createDisplayPersistence({ store, createId: createClientId });
|
||||
clientId = await persistence.getOrCreateClientId();
|
||||
} catch {
|
||||
// A display with no usable IndexedDB still shows slides; it simply
|
||||
// cannot persist its identity between restarts.
|
||||
clientId = createClientId();
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
// Handlers are registered before the socket connects so the first
|
||||
// authoritative snapshot can never be missed.
|
||||
const socket = io({ autoConnect: false });
|
||||
controller = createDisplayController({
|
||||
assets: createAssetManager(),
|
||||
clientId,
|
||||
gate: gateRef.current,
|
||||
layers: [
|
||||
createSceneLayer(layerZeroRef.current),
|
||||
createSceneLayer(layerOneRef.current),
|
||||
],
|
||||
persistence,
|
||||
socket,
|
||||
});
|
||||
controllerRef.current = controller;
|
||||
if (typeof window !== "undefined") {
|
||||
// Test and support hook only; it exposes no private storage details.
|
||||
window.__display = {
|
||||
clientId,
|
||||
diagnostics: () => controller.diagnostics(),
|
||||
heartbeat: () => controller.sendHeartbeat(),
|
||||
};
|
||||
}
|
||||
controller.start();
|
||||
}
|
||||
|
||||
void boot();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller?.destroy();
|
||||
controllerRef.current = null;
|
||||
if (typeof window !== "undefined") delete window.__display;
|
||||
try {
|
||||
database?.close();
|
||||
} catch {
|
||||
// Closing a database that never opened is not an error worth raising.
|
||||
}
|
||||
delete document.body.dataset.displayRoute;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="display-root" data-testid="display-root">
|
||||
<SceneMarkup index={0} layerRef={layerZeroRef} />
|
||||
<SceneMarkup index={1} layerRef={layerOneRef} />
|
||||
<div
|
||||
className="output-gate"
|
||||
data-testid="output-gate"
|
||||
data-active="false"
|
||||
ref={gateRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createDisplayError,
|
||||
serializeDisplayError,
|
||||
shouldClearError,
|
||||
MAX_SERIALIZED_LENGTH,
|
||||
} from "../errors.js";
|
||||
import { isSixteenByNine, resolveScenePresentation } from "../presentation.js";
|
||||
import {
|
||||
canPredictLocally,
|
||||
classifySnapshot,
|
||||
entryPlayableAt,
|
||||
predictNextEntry,
|
||||
targetKey,
|
||||
} from "../snapshot.js";
|
||||
|
||||
function entry(overrides = {}) {
|
||||
return {
|
||||
id: "entry-a",
|
||||
position: 0,
|
||||
assetId: "asset-a",
|
||||
effectiveDurationMs: 5_000,
|
||||
scheduleStartAt: null,
|
||||
scheduleEndAt: null,
|
||||
expirationAt: null,
|
||||
playable: true,
|
||||
effectivePresentation: {
|
||||
backgroundMode: "blurred",
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
blurPx: 20,
|
||||
backgroundDarkeningOpacity: 0,
|
||||
},
|
||||
asset: { id: "asset-a", media: { url: "/media/a.webp", sha256: "a", width: 1920, height: 1080 } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
return {
|
||||
playbackRevision: 5,
|
||||
settingsRevision: 2,
|
||||
serverTime: 1_000,
|
||||
mode: "on",
|
||||
transportState: "playing",
|
||||
activePlaylistId: "playlist-a",
|
||||
pendingPlaylistId: null,
|
||||
currentEntryId: "entry-a",
|
||||
crossfadeDurationMs: 500,
|
||||
timeline: { slideStartedAt: 0, nextSlideAt: 5_000 },
|
||||
normalTarget: { type: "normal", ...entry() },
|
||||
staticTarget: null,
|
||||
manifest: { playlistId: "playlist-a", playlistRevision: 3, entries: [entry()] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("16:9 detection uses real pixel dimensions with a small tolerance", () => {
|
||||
assert.equal(isSixteenByNine(1920, 1080), true);
|
||||
assert.equal(isSixteenByNine(1918, 1080), true, "near-16:9 encodes still fill");
|
||||
assert.equal(isSixteenByNine(1280, 720), true);
|
||||
assert.equal(isSixteenByNine(1600, 1000), false, "16:10 is not 16:9");
|
||||
assert.equal(isSixteenByNine(1080, 1920), false, "portrait is not 16:9");
|
||||
assert.equal(isSixteenByNine(1024, 768), false, "4:3 is not 16:9");
|
||||
assert.equal(isSixteenByNine(0, 0), false);
|
||||
});
|
||||
|
||||
test("a true 16:9 slide fills the frame with no backdrop or shadow", () => {
|
||||
const presentation = resolveScenePresentation(entry());
|
||||
assert.equal(presentation.fit, "fill");
|
||||
assert.equal(presentation.backgroundImage, false);
|
||||
assert.equal(presentation.backgroundMode, "none");
|
||||
assert.equal(presentation.shadow, false);
|
||||
assert.equal(presentation.darkenOpacity, 0);
|
||||
});
|
||||
|
||||
test("a non-16:9 slide is contained over its configured background", () => {
|
||||
const portrait = entry({
|
||||
asset: { id: "p", media: { url: "/media/p.webp", sha256: "p", width: 1080, height: 1920 } },
|
||||
});
|
||||
const blurred = resolveScenePresentation(portrait);
|
||||
assert.equal(blurred.fit, "contain");
|
||||
assert.equal(blurred.backgroundMode, "blurred");
|
||||
assert.equal(blurred.backgroundImage, true);
|
||||
assert.equal(blurred.blurPx, 20, "the global default blur is used");
|
||||
assert.equal(blurred.shadow, true);
|
||||
|
||||
const solid = resolveScenePresentation({
|
||||
...portrait,
|
||||
effectivePresentation: {
|
||||
backgroundMode: "solid",
|
||||
backgroundColor: "#123456",
|
||||
backgroundDarkened: true,
|
||||
blurPx: 20,
|
||||
backgroundDarkeningOpacity: 0.25,
|
||||
},
|
||||
});
|
||||
assert.equal(solid.backgroundMode, "solid");
|
||||
assert.equal(solid.backgroundColor, "#123456");
|
||||
assert.equal(solid.backgroundImage, false);
|
||||
assert.equal(solid.darkened, true);
|
||||
assert.equal(solid.darkenOpacity, 0.25);
|
||||
|
||||
const black = resolveScenePresentation({
|
||||
...portrait,
|
||||
effectivePresentation: { backgroundMode: "black", backgroundColor: "#000000", blurPx: 20 },
|
||||
});
|
||||
assert.equal(black.backgroundMode, "black");
|
||||
assert.equal(black.backgroundImage, false);
|
||||
assert.equal(black.darkenOpacity, 0);
|
||||
});
|
||||
|
||||
test("a stale playback revision is rejected but an equal one is not", () => {
|
||||
const accepted = snapshot();
|
||||
assert.equal(classifySnapshot(null, accepted).accept, true);
|
||||
assert.equal(
|
||||
classifySnapshot(accepted, snapshot({ playbackRevision: 4 })).accept,
|
||||
false,
|
||||
"an older playback revision is stale",
|
||||
);
|
||||
assert.equal(classifySnapshot(accepted, snapshot({ playbackRevision: 6 })).accept, true);
|
||||
});
|
||||
|
||||
test("manifest and settings changes are accepted at an unchanged playback revision", () => {
|
||||
const accepted = snapshot();
|
||||
const settings = classifySnapshot(accepted, snapshot({ settingsRevision: 3 }));
|
||||
assert.equal(settings.accept, true);
|
||||
assert.equal(settings.reason, "settings_revision");
|
||||
|
||||
const reordered = classifySnapshot(
|
||||
accepted,
|
||||
snapshot({
|
||||
manifest: {
|
||||
playlistId: "playlist-a",
|
||||
playlistRevision: 4,
|
||||
entries: [entry({ id: "entry-b", position: 1 }), entry()],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(reordered.accept, true);
|
||||
assert.equal(reordered.reason, "manifest");
|
||||
|
||||
const identical = classifySnapshot(accepted, snapshot());
|
||||
assert.equal(identical.accept, true);
|
||||
assert.equal(identical.unchanged, true, "an identical snapshot implies no visual work");
|
||||
});
|
||||
|
||||
test("schedules are half open and rotation skips locally unavailable entries", () => {
|
||||
const scheduled = entry({ scheduleStartAt: 100, scheduleEndAt: 200 });
|
||||
assert.equal(entryPlayableAt(scheduled, 99), false);
|
||||
assert.equal(entryPlayableAt(scheduled, 100), true, "active exactly at start");
|
||||
assert.equal(entryPlayableAt(scheduled, 199), true);
|
||||
assert.equal(entryPlayableAt(scheduled, 200), false, "inactive exactly at end");
|
||||
|
||||
const manifest = {
|
||||
entries: [
|
||||
entry({ id: "a", position: 0 }),
|
||||
entry({ id: "b", position: 1 }),
|
||||
entry({ id: "c", position: 2 }),
|
||||
],
|
||||
};
|
||||
assert.equal(predictNextEntry(manifest, "a", 0).id, "b");
|
||||
assert.equal(predictNextEntry(manifest, "c", 0).id, "a", "rotation wraps");
|
||||
assert.equal(predictNextEntry(manifest, "a", 0, ["b"]).id, "c", "a bad asset is skipped");
|
||||
assert.equal(predictNextEntry(manifest, "a", 0, ["b", "c"]).id, "a");
|
||||
assert.equal(predictNextEntry({ entries: [] }, "a", 0), null);
|
||||
});
|
||||
|
||||
test("local prediction is withheld when a pending activation owns the next boundary", () => {
|
||||
assert.equal(canPredictLocally(snapshot()), true);
|
||||
assert.equal(
|
||||
canPredictLocally(snapshot({ pendingPlaylistId: "playlist-b" })),
|
||||
false,
|
||||
"the next boundary belongs to a playlist the display may not hold yet",
|
||||
);
|
||||
assert.equal(canPredictLocally(snapshot({ transportState: "paused" })), false);
|
||||
assert.equal(
|
||||
canPredictLocally(snapshot({ staticTarget: { assetId: "s" }, transportState: "static" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(canPredictLocally(snapshot({ mode: "off" })), true, "mode off still advances");
|
||||
});
|
||||
|
||||
test("target keys distinguish mode, static, normal, and empty output", () => {
|
||||
assert.equal(targetKey(snapshot({ mode: "off" })), "black:mode_off");
|
||||
assert.match(targetKey(snapshot()), /^normal:entry-a:a$/);
|
||||
assert.match(
|
||||
targetKey(snapshot({ staticTarget: { assetId: "s", asset: { media: { sha256: "z" } } } })),
|
||||
/^static:s:z$/,
|
||||
);
|
||||
assert.equal(targetKey(snapshot({ normalTarget: null })), "black:no_playable_content");
|
||||
});
|
||||
|
||||
test("display errors are structured, bounded, and cleared only when resolved", () => {
|
||||
const error = createDisplayError("slide_decode_failed", {
|
||||
assetId: "asset-a",
|
||||
entryId: "entry-a",
|
||||
message: "x".repeat(5_000),
|
||||
});
|
||||
assert.equal(error.kind, "slide_decode_failed");
|
||||
assert.ok(error.message.length <= 240);
|
||||
const serialized = serializeDisplayError(error);
|
||||
assert.ok(serialized.length <= MAX_SERIALIZED_LENGTH);
|
||||
assert.equal(JSON.parse(serialized).assetId, "asset-a");
|
||||
assert.throws(() => createDisplayError("nonsense"), TypeError);
|
||||
|
||||
assert.equal(
|
||||
shouldClearError(error, { renderedAssetId: "asset-b", manifestAssetIds: ["asset-a", "asset-b"] }),
|
||||
false,
|
||||
"a still-present problem is never cleared by time alone",
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearError(error, { renderedAssetId: "asset-a", manifestAssetIds: ["asset-a"] }),
|
||||
true,
|
||||
"the failing asset rendering resolves the condition",
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearError(error, { renderedAssetId: "asset-b", manifestAssetIds: ["asset-b"] }),
|
||||
true,
|
||||
"the failing entry leaving the manifest resolves the condition",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { DISPLAY_ERROR_KINDS, createDisplayError } from "./errors.js";
|
||||
|
||||
// Signage reliability must not depend on ordinary HTTP caching. Media URLs are
|
||||
// content-addressed and immutable, so bytes are stored explicitly here and a
|
||||
// changed asset always arrives under a new URL.
|
||||
export const ASSET_CACHE_NAME = "announcements-display-assets-v1";
|
||||
|
||||
export class DisplayAssetError extends Error {
|
||||
constructor(displayError, cause) {
|
||||
super(displayError.message);
|
||||
this.name = "DisplayAssetError";
|
||||
this.displayError = displayError;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetManager({
|
||||
cacheName = ASSET_CACHE_NAME,
|
||||
fetchFn = (...args) => fetch(...args),
|
||||
caches: cacheStorage = typeof caches === "undefined" ? null : caches,
|
||||
} = {}) {
|
||||
// url -> { objectUrl, refCount }
|
||||
const objectUrls = new Map();
|
||||
let cachePromise = null;
|
||||
|
||||
function cache() {
|
||||
if (!cacheStorage) {
|
||||
return Promise.reject(new Error("Cache Storage is unavailable"));
|
||||
}
|
||||
cachePromise = cachePromise || cacheStorage.open(cacheName);
|
||||
return cachePromise;
|
||||
}
|
||||
|
||||
async function cachedUrls() {
|
||||
try {
|
||||
const store = await cache();
|
||||
const keys = await store.keys();
|
||||
return keys.map((request) => new URL(request.url).pathname);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function count() {
|
||||
return (await cachedUrls()).length;
|
||||
}
|
||||
|
||||
async function has(url) {
|
||||
try {
|
||||
const store = await cache();
|
||||
return Boolean(await store.match(url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a blob for the URL, fetching and storing it only when the explicit
|
||||
// cache does not already hold it.
|
||||
async function loadBlob(url, { assetId = null, entryId = null } = {}) {
|
||||
let store;
|
||||
try {
|
||||
store = await cache();
|
||||
} catch (error) {
|
||||
throw new DisplayAssetError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.CACHE_WRITE_FAILED, {
|
||||
assetId,
|
||||
entryId,
|
||||
message: `Cache Storage unavailable: ${error?.message ?? "unknown"}`,
|
||||
}),
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const cached = await store.match(url);
|
||||
if (cached) return cached.blob();
|
||||
|
||||
let response;
|
||||
try {
|
||||
// The explicit cache is the source of truth, so the HTTP cache is bypassed.
|
||||
response = await fetchFn(url, { cache: "no-store" });
|
||||
} catch (error) {
|
||||
throw new DisplayAssetError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.ASSET_FETCH_FAILED, {
|
||||
assetId,
|
||||
entryId,
|
||||
message: `Fetch failed for ${url}: ${error?.message ?? "network error"}`,
|
||||
}),
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DisplayAssetError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.ASSET_FETCH_FAILED, {
|
||||
assetId,
|
||||
entryId,
|
||||
message: `Fetch failed for ${url}: HTTP ${response.status}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await store.put(url, response.clone());
|
||||
} catch {
|
||||
// Quota pressure is recoverable: prune and try once more before giving up.
|
||||
try {
|
||||
await pruneTo(new Set(objectUrls.keys()));
|
||||
await store.put(url, response.clone());
|
||||
} catch (retryError) {
|
||||
throw new DisplayAssetError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.CACHE_WRITE_FAILED, {
|
||||
assetId,
|
||||
entryId,
|
||||
message: `Could not store ${url}: ${retryError?.message ?? "quota exceeded"}`,
|
||||
}),
|
||||
retryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// Object URLs are reference counted so a layer that is still showing an image
|
||||
// never has its bytes revoked underneath it.
|
||||
async function acquire(url, context) {
|
||||
const existing = objectUrls.get(url);
|
||||
if (existing) {
|
||||
existing.refCount += 1;
|
||||
return existing.objectUrl;
|
||||
}
|
||||
const blob = await loadBlob(url, context);
|
||||
const found = objectUrls.get(url);
|
||||
if (found) {
|
||||
found.refCount += 1;
|
||||
return found.objectUrl;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrls.set(url, { objectUrl, refCount: 1 });
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
function release(url) {
|
||||
const entry = objectUrls.get(url);
|
||||
if (!entry) return;
|
||||
entry.refCount -= 1;
|
||||
if (entry.refCount > 0) return;
|
||||
URL.revokeObjectURL(entry.objectUrl);
|
||||
objectUrls.delete(url);
|
||||
}
|
||||
|
||||
function activeObjectUrlCount() {
|
||||
return objectUrls.size;
|
||||
}
|
||||
|
||||
// Conservative pruning: anything still referenced by retained state or held
|
||||
// by a live scene layer is kept.
|
||||
async function pruneTo(retainedUrls) {
|
||||
const retained = new Set(retainedUrls);
|
||||
for (const url of objectUrls.keys()) retained.add(url);
|
||||
let removed = 0;
|
||||
try {
|
||||
const store = await cache();
|
||||
for (const request of await store.keys()) {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (retained.has(path)) continue;
|
||||
if (await store.delete(request)) removed += 1;
|
||||
}
|
||||
} catch {
|
||||
// Pruning is opportunistic; a failure must never break playback.
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
return {
|
||||
acquire,
|
||||
activeObjectUrlCount,
|
||||
cachedUrls,
|
||||
count,
|
||||
has,
|
||||
loadBlob,
|
||||
pruneTo,
|
||||
release,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Signage-only styling. These fullscreen constraints are scoped to the display
|
||||
route so the future management dashboard is unaffected. */
|
||||
body[data-display-route="true"] {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.display-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.display-root *:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Two permanent layers. Only opacity and content change; they are never
|
||||
recreated, and an outgoing layer is never faded out. */
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
background: #000;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: linear;
|
||||
transition-duration: 0ms;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.scene-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Overscanned so the blur never reveals a transparent or black edge. */
|
||||
.scene-background-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transform: scale(1.08);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scene-darken {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene-foreground {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Topmost black gate for mode off. It covers the output instantly while the
|
||||
renderer keeps tracking the authoritative timeline underneath. */
|
||||
.output-gate {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.output-gate[data-active="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createAssetManager, DisplayAssetError } from "./assetCache.js";
|
||||
import { DISPLAY_ERROR_KINDS, createDisplayError, serializeDisplayError, shouldClearError } from "./errors.js";
|
||||
import { resolveScenePresentation } from "./presentation.js";
|
||||
import {
|
||||
canPredictLocally,
|
||||
classifySnapshot,
|
||||
predictNextEntry,
|
||||
targetKey,
|
||||
} from "./snapshot.js";
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 12_000;
|
||||
const HEARTBEAT_PROTOCOL_VERSION = 1;
|
||||
|
||||
function presentationSignature(presentation) {
|
||||
return [
|
||||
presentation.fit,
|
||||
presentation.backgroundMode,
|
||||
presentation.backgroundColor,
|
||||
presentation.blurPx,
|
||||
presentation.darkenOpacity,
|
||||
presentation.shadow ? 1 : 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function delay(ms, setTimeoutFn) {
|
||||
return new Promise((resolve) => setTimeoutFn(resolve, ms));
|
||||
}
|
||||
|
||||
export function createDisplayController({
|
||||
layers,
|
||||
gate,
|
||||
socket,
|
||||
persistence,
|
||||
clientId,
|
||||
assets = createAssetManager(),
|
||||
now = Date.now,
|
||||
setTimeoutFn = (...args) => setTimeout(...args),
|
||||
clearTimeoutFn = (...args) => clearTimeout(...args),
|
||||
setIntervalFn = (...args) => setInterval(...args),
|
||||
clearIntervalFn = (...args) => clearInterval(...args),
|
||||
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
|
||||
onDiagnostics = () => {},
|
||||
} = {}) {
|
||||
let accepted = null;
|
||||
let serverOffset = 0;
|
||||
let generation = 0;
|
||||
let currentIndex = 0;
|
||||
let renderedSceneKey = null;
|
||||
let renderedTarget = null;
|
||||
let promotedManifest = { entries: [] };
|
||||
let predictedEntryId = null;
|
||||
let predictionTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let currentError = null;
|
||||
let destroyed = false;
|
||||
let stagingToken = 0;
|
||||
const layerUrls = [null, null];
|
||||
const unavailableEntryIds = new Set();
|
||||
const failedMediaUrls = new Set();
|
||||
|
||||
function diagnostics() {
|
||||
const snapshot = {
|
||||
acceptedRevision: accepted?.playbackRevision ?? null,
|
||||
serverOffset,
|
||||
generation,
|
||||
currentIndex,
|
||||
renderedSceneKey,
|
||||
renderedAssetId: renderedTarget?.assetId ?? null,
|
||||
renderedEntryId: renderedTarget?.entryId ?? null,
|
||||
predictedEntryId,
|
||||
unavailableEntryIds: [...unavailableEntryIds],
|
||||
mostRecentError: currentError,
|
||||
activeObjectUrls: assets.activeObjectUrlCount(),
|
||||
};
|
||||
onDiagnostics(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function estimatedServerNow() {
|
||||
return now() + serverOffset;
|
||||
}
|
||||
|
||||
function recordError(displayError) {
|
||||
currentError = displayError;
|
||||
diagnostics();
|
||||
}
|
||||
|
||||
function clearErrorIfResolved() {
|
||||
const manifestAssetIds = (promotedManifest.entries || []).map(
|
||||
(entry) => entry.assetId,
|
||||
);
|
||||
if (
|
||||
shouldClearError(currentError, {
|
||||
renderedAssetId: renderedTarget?.assetId ?? null,
|
||||
manifestAssetIds,
|
||||
})
|
||||
) {
|
||||
currentError = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rendering
|
||||
|
||||
function buildScene(target, kind) {
|
||||
const presentation = resolveScenePresentation(target);
|
||||
const mediaUrl = target?.asset?.media?.url;
|
||||
if (!mediaUrl) return null;
|
||||
return {
|
||||
assetId: target.assetId ?? target.asset?.id ?? null,
|
||||
entryId: kind === "normal" ? target.id : null,
|
||||
key: `${targetKey({
|
||||
mode: "on",
|
||||
staticTarget: kind === "static" ? target : null,
|
||||
normalTarget: kind === "normal" ? target : null,
|
||||
})}#${presentationSignature(presentation)}`,
|
||||
kind,
|
||||
mediaUrl,
|
||||
presentation,
|
||||
};
|
||||
}
|
||||
|
||||
async function showScene(scene, token, crossfadeMs) {
|
||||
if (destroyed || token !== generation) return false;
|
||||
if (scene.key === renderedSceneKey) return true;
|
||||
|
||||
let objectUrl;
|
||||
try {
|
||||
objectUrl = await assets.acquire(scene.mediaUrl, {
|
||||
assetId: scene.assetId,
|
||||
entryId: scene.entryId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DisplayAssetError) {
|
||||
failedMediaUrls.add(scene.mediaUrl);
|
||||
if (scene.entryId) unavailableEntryIds.add(scene.entryId);
|
||||
recordError(error.displayError);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Authoritative state may have moved on while bytes were loading.
|
||||
if (destroyed || token !== generation) {
|
||||
assets.release(scene.mediaUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
const incomingIndex = 1 - currentIndex;
|
||||
const incoming = layers[incomingIndex];
|
||||
const outgoing = layers[currentIndex];
|
||||
const previousUrl = layerUrls[incomingIndex];
|
||||
|
||||
try {
|
||||
await incoming.prepare({ ...scene, objectUrl });
|
||||
} catch (error) {
|
||||
assets.release(scene.mediaUrl);
|
||||
failedMediaUrls.add(scene.mediaUrl);
|
||||
if (scene.entryId) unavailableEntryIds.add(scene.entryId);
|
||||
// A decode failure must never remove the last good frame.
|
||||
recordError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.SLIDE_DECODE_FAILED, {
|
||||
assetId: scene.assetId,
|
||||
entryId: scene.entryId,
|
||||
message: `Could not decode ${scene.mediaUrl}: ${error?.message ?? "decode failed"}`,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (destroyed || token !== generation) {
|
||||
assets.release(scene.mediaUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The incoming layer rises above the outgoing one at zero opacity and fades
|
||||
// in. The outgoing layer is never faded out, so the black root can never
|
||||
// contribute to the composite mid-transition.
|
||||
incoming.setOpacity(0, 0);
|
||||
incoming.setStacking(2);
|
||||
outgoing.setStacking(1);
|
||||
incoming.forceReflow();
|
||||
incoming.setOpacity(1, crossfadeMs);
|
||||
if (crossfadeMs > 0) await delay(crossfadeMs, setTimeoutFn);
|
||||
|
||||
if (destroyed || token !== generation) {
|
||||
// A newer transition won while this one was fading; it owns the layers.
|
||||
return false;
|
||||
}
|
||||
|
||||
currentIndex = incomingIndex;
|
||||
layerUrls[incomingIndex] = scene.mediaUrl;
|
||||
if (previousUrl && previousUrl !== scene.mediaUrl) assets.release(previousUrl);
|
||||
renderedSceneKey = scene.key;
|
||||
renderedTarget = {
|
||||
assetId: scene.assetId,
|
||||
entryId: scene.entryId,
|
||||
kind: scene.kind,
|
||||
mediaUrl: scene.mediaUrl,
|
||||
};
|
||||
clearErrorIfResolved();
|
||||
diagnostics();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setGate(on) {
|
||||
gate.dataset.active = on ? "true" : "false";
|
||||
gate.style.opacity = on ? "1" : "0";
|
||||
}
|
||||
|
||||
function hideContent() {
|
||||
for (const layer of layers) layer.setOpacity(0, 0);
|
||||
renderedSceneKey = null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ staging
|
||||
|
||||
// A new manifest never destroys the working one. Assets are fetched and
|
||||
// validated first; the manifest is promoted only once it is usable.
|
||||
async function stageManifest(snapshot, token) {
|
||||
const entries = snapshot.manifest?.entries || [];
|
||||
let usable = 0;
|
||||
for (const entry of entries) {
|
||||
if (destroyed || token !== stagingToken) return;
|
||||
const url = entry.asset?.media?.url;
|
||||
if (!url) {
|
||||
unavailableEntryIds.add(entry.id);
|
||||
continue;
|
||||
}
|
||||
if (failedMediaUrls.has(url)) {
|
||||
unavailableEntryIds.add(entry.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await assets.loadBlob(url, { assetId: entry.assetId, entryId: entry.id });
|
||||
unavailableEntryIds.delete(entry.id);
|
||||
usable += 1;
|
||||
} catch (error) {
|
||||
// One bad asset marks only itself unavailable; the playlist survives.
|
||||
unavailableEntryIds.add(entry.id);
|
||||
if (error instanceof DisplayAssetError) recordError(error.displayError);
|
||||
}
|
||||
}
|
||||
|
||||
if (destroyed || token !== stagingToken) return;
|
||||
|
||||
const expectsPlayable = entries.some((entry) => entry.playable);
|
||||
if (expectsPlayable && usable === 0) {
|
||||
// The server believes this playlist is playable but nothing renders here.
|
||||
// The last good frame stays up and the condition is reported.
|
||||
recordError(
|
||||
createDisplayError(DISPLAY_ERROR_KINDS.MANIFEST_INVALID, {
|
||||
message: `No playable slide of playlist ${
|
||||
snapshot.activePlaylistId ?? "unknown"
|
||||
} could be loaded locally`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
promotedManifest = snapshot.manifest || { entries: [] };
|
||||
clearErrorIfResolved();
|
||||
|
||||
const retained = new Set();
|
||||
for (const entry of promotedManifest.entries || []) {
|
||||
if (entry.asset?.media?.url) retained.add(entry.asset.media.url);
|
||||
}
|
||||
if (snapshot.staticTarget?.asset?.media?.url) {
|
||||
retained.add(snapshot.staticTarget.asset.media.url);
|
||||
}
|
||||
for (const url of layerUrls) if (url) retained.add(url);
|
||||
await assets.pruneTo(retained);
|
||||
|
||||
try {
|
||||
await persistence?.writeLastKnownGood(snapshot, {
|
||||
mostRecentError: currentError,
|
||||
});
|
||||
} catch {
|
||||
// Persistence is best effort and never blocks rendering.
|
||||
}
|
||||
diagnostics();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- prediction
|
||||
|
||||
function clearPrediction() {
|
||||
if (predictionTimer !== null) {
|
||||
clearTimeoutFn(predictionTimer);
|
||||
predictionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function armPrediction(serverDeadline) {
|
||||
clearPrediction();
|
||||
if (destroyed || !accepted) return;
|
||||
if (!canPredictLocally(accepted)) return;
|
||||
const localDelay = Math.max(0, serverDeadline - serverOffset - now());
|
||||
predictionTimer = setTimeoutFn(() => {
|
||||
predictionTimer = null;
|
||||
void runPrediction();
|
||||
}, localDelay);
|
||||
}
|
||||
|
||||
async function runPrediction() {
|
||||
if (destroyed || !accepted) return;
|
||||
if (!canPredictLocally(accepted)) return;
|
||||
const at = estimatedServerNow();
|
||||
const next = predictNextEntry(
|
||||
promotedManifest,
|
||||
predictedEntryId,
|
||||
at,
|
||||
[...unavailableEntryIds],
|
||||
);
|
||||
if (!next) {
|
||||
// Nothing locally usable: hold the current good frame and wait for the
|
||||
// server rather than blanking the screen.
|
||||
return;
|
||||
}
|
||||
predictedEntryId = next.id;
|
||||
generation += 1;
|
||||
const token = generation;
|
||||
const scene = buildScene(next, "normal");
|
||||
if (scene) await showScene(scene, token, accepted.crossfadeDurationMs ?? 0);
|
||||
armPrediction(at + (next.effectiveDurationMs ?? 0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshots
|
||||
|
||||
async function applySnapshot(snapshot) {
|
||||
if (destroyed) return { accepted: false, reason: "destroyed" };
|
||||
const decision = classifySnapshot(accepted, snapshot);
|
||||
if (!decision.accept) {
|
||||
diagnostics();
|
||||
return { accepted: false, reason: decision.reason };
|
||||
}
|
||||
|
||||
// Packet arrival time is not slide start time; re-anchor on every accepted
|
||||
// snapshot so local scheduling follows the server's clock.
|
||||
serverOffset = snapshot.serverTime - now();
|
||||
accepted = snapshot;
|
||||
predictedEntryId = snapshot.currentEntryId ?? null;
|
||||
clearPrediction();
|
||||
|
||||
generation += 1;
|
||||
const token = generation;
|
||||
stagingToken += 1;
|
||||
const staging = stagingToken;
|
||||
|
||||
setGate(snapshot.mode === "off");
|
||||
|
||||
const target = snapshot.staticTarget
|
||||
? buildScene(snapshot.staticTarget, "static")
|
||||
: snapshot.normalTarget
|
||||
? buildScene(snapshot.normalTarget, "normal")
|
||||
: null;
|
||||
|
||||
if (!target) {
|
||||
// The server itself says there is nothing playable: black is correct.
|
||||
if (snapshot.mode !== "off") hideContent();
|
||||
renderedTarget = null;
|
||||
} else {
|
||||
await showScene(target, token, snapshot.crossfadeDurationMs ?? 0);
|
||||
}
|
||||
|
||||
void stageManifest(snapshot, staging);
|
||||
if (snapshot.timeline?.nextSlideAt != null) {
|
||||
armPrediction(snapshot.timeline.nextSlideAt);
|
||||
}
|
||||
diagnostics();
|
||||
return { accepted: true, reason: decision.reason };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- heartbeat
|
||||
|
||||
async function sendHeartbeat() {
|
||||
if (destroyed) return null;
|
||||
let cachedAssetCount;
|
||||
try {
|
||||
cachedAssetCount = await assets.count();
|
||||
} catch {
|
||||
cachedAssetCount = 0;
|
||||
}
|
||||
const entries = promotedManifest.entries || [];
|
||||
const playableAssetCount = entries.filter(
|
||||
(entry) => entry.playable && !unavailableEntryIds.has(entry.id),
|
||||
).length;
|
||||
const payload = {
|
||||
protocolVersion: HEARTBEAT_PROTOCOL_VERSION,
|
||||
clientId,
|
||||
outputId: accepted?.outputId || "global",
|
||||
activePlaylistId: accepted?.activePlaylistId ?? null,
|
||||
playlistRevision: accepted?.manifest?.playlistRevision ?? null,
|
||||
playbackRevision: accepted?.playbackRevision ?? null,
|
||||
currentEntryId: renderedTarget?.entryId ?? null,
|
||||
currentAssetId: renderedTarget?.assetId ?? null,
|
||||
cachedAssetCount,
|
||||
playableAssetCount,
|
||||
mostRecentError: serializeDisplayError(currentError),
|
||||
};
|
||||
// Telemetry only. The display never mutates authoritative playback.
|
||||
socket.emit("display:heartbeat", payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
if (heartbeatTimer !== null) return;
|
||||
heartbeatTimer = setIntervalFn(() => {
|
||||
void sendHeartbeat();
|
||||
}, heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer === null) return;
|
||||
clearIntervalFn(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
function start() {
|
||||
socket.on("playback:state", (snapshot) => {
|
||||
void applySnapshot(snapshot);
|
||||
});
|
||||
socket.on("connect", () => {
|
||||
// A reconnect resynchronizes from the server; it never reloads the page
|
||||
// and never clears the visible frame.
|
||||
void sendHeartbeat();
|
||||
diagnostics();
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
// The last good scene stays exactly where it is.
|
||||
clearPrediction();
|
||||
diagnostics();
|
||||
});
|
||||
socket.connect();
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
generation += 1;
|
||||
stagingToken += 1;
|
||||
clearPrediction();
|
||||
stopHeartbeat();
|
||||
socket.off("playback:state");
|
||||
socket.off("connect");
|
||||
socket.off("disconnect");
|
||||
socket.disconnect();
|
||||
for (const url of layerUrls) if (url) assets.release(url);
|
||||
layerUrls[0] = null;
|
||||
layerUrls[1] = null;
|
||||
}
|
||||
|
||||
return {
|
||||
applySnapshot,
|
||||
destroy,
|
||||
diagnostics,
|
||||
estimatedServerNow,
|
||||
sendHeartbeat,
|
||||
start,
|
||||
get state() {
|
||||
return {
|
||||
accepted,
|
||||
currentError,
|
||||
promotedManifest,
|
||||
renderedTarget,
|
||||
serverOffset,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// A deliberately tiny IndexedDB layer: one database, one key-value store, and
|
||||
// explicit version handling. This is not a general persistence framework, and
|
||||
// it stays small enough to reason about rather than hiding upgrade footguns
|
||||
// behind an abstraction.
|
||||
export const DISPLAY_DB_NAME = "announcements-display";
|
||||
export const DISPLAY_DB_VERSION = 1;
|
||||
export const DISPLAY_STORE = "state";
|
||||
|
||||
export const STATE_KEYS = Object.freeze({
|
||||
CLIENT_ID: "clientId",
|
||||
LAST_KNOWN_GOOD: "lastKnownGood",
|
||||
SCHEMA: "schema",
|
||||
});
|
||||
|
||||
function request(operation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
operation.onsuccess = () => resolve(operation.result);
|
||||
operation.onerror = () => reject(operation.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function openDisplayDatabase({
|
||||
indexedDB: factory = typeof indexedDB === "undefined" ? null : indexedDB,
|
||||
name = DISPLAY_DB_NAME,
|
||||
version = DISPLAY_DB_VERSION,
|
||||
} = {}) {
|
||||
if (!factory) return Promise.reject(new Error("IndexedDB is unavailable"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = factory.open(name, version);
|
||||
open.onupgradeneeded = (event) => {
|
||||
const database = open.result;
|
||||
// Every version step is explicit. Version 1 creates the single store.
|
||||
if (event.oldVersion < 1 && !database.objectStoreNames.contains(DISPLAY_STORE)) {
|
||||
database.createObjectStore(DISPLAY_STORE);
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
open.onblocked = () => reject(new Error("IndexedDB upgrade is blocked"));
|
||||
});
|
||||
}
|
||||
|
||||
export function createDisplayStore({ database }) {
|
||||
async function read(key) {
|
||||
const transaction = database.transaction(DISPLAY_STORE, "readonly");
|
||||
return request(transaction.objectStore(DISPLAY_STORE).get(key));
|
||||
}
|
||||
|
||||
async function write(key, value) {
|
||||
const transaction = database.transaction(DISPLAY_STORE, "readwrite");
|
||||
const result = request(transaction.objectStore(DISPLAY_STORE).put(value, key));
|
||||
await result;
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve(value);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(key) {
|
||||
const transaction = database.transaction(DISPLAY_STORE, "readwrite");
|
||||
await request(transaction.objectStore(DISPLAY_STORE).delete(key));
|
||||
}
|
||||
|
||||
return { read, write, remove };
|
||||
}
|
||||
|
||||
// Persisted state that survives a refresh, a reconnect, and a Chromium restart.
|
||||
// Reading is defensive: a partially written or older record is discarded rather
|
||||
// than allowed to corrupt startup.
|
||||
export function isValidLastKnownGood(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
if (value.schemaVersion !== DISPLAY_DB_VERSION) return false;
|
||||
if (typeof value.playbackRevision !== "number") return false;
|
||||
if (!value.manifest || typeof value.manifest !== "object") return false;
|
||||
if (!Array.isArray(value.manifest.entries)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createDisplayPersistence({ store, createId, now = Date.now }) {
|
||||
async function getOrCreateClientId() {
|
||||
const existing = await store.read(STATE_KEYS.CLIENT_ID);
|
||||
if (typeof existing === "string" && existing.trim()) return existing;
|
||||
const clientId = createId();
|
||||
await store.write(STATE_KEYS.CLIENT_ID, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
async function readLastKnownGood() {
|
||||
let value;
|
||||
try {
|
||||
value = await store.read(STATE_KEYS.LAST_KNOWN_GOOD);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isValidLastKnownGood(value)) {
|
||||
// Malformed or superseded records fail safe: the display starts fresh
|
||||
// from the server rather than trusting an unreadable snapshot.
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function writeLastKnownGood(snapshot, extra = {}) {
|
||||
const record = {
|
||||
schemaVersion: DISPLAY_DB_VERSION,
|
||||
savedAt: now(),
|
||||
playbackRevision: snapshot.playbackRevision,
|
||||
settingsRevision: snapshot.settingsRevision ?? null,
|
||||
playlistRevision: snapshot.manifest?.playlistRevision ?? null,
|
||||
activePlaylistId: snapshot.activePlaylistId ?? null,
|
||||
mode: snapshot.mode,
|
||||
transportState: snapshot.transportState,
|
||||
currentEntryId: snapshot.currentEntryId ?? null,
|
||||
staticAssetId: snapshot.staticTarget?.assetId ?? null,
|
||||
// The timing anchor is what lets a future offline start reason about
|
||||
// where playback should be, without a server to ask.
|
||||
timing: {
|
||||
serverTime: snapshot.serverTime,
|
||||
slideStartedAt: snapshot.timeline?.slideStartedAt ?? null,
|
||||
nextSlideAt: snapshot.timeline?.nextSlideAt ?? null,
|
||||
crossfadeDurationMs: snapshot.crossfadeDurationMs ?? 0,
|
||||
recordedAt: now(),
|
||||
},
|
||||
manifest: snapshot.manifest || { entries: [] },
|
||||
staticTarget: snapshot.staticTarget || null,
|
||||
mostRecentError: extra.mostRecentError ?? null,
|
||||
};
|
||||
await store.write(STATE_KEYS.LAST_KNOWN_GOOD, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
return { getOrCreateClientId, readLastKnownGood, writeLastKnownGood };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Structured display error state. The heartbeat carries the most recent one so
|
||||
// operations can see why a screen is wrong without opening the kiosk.
|
||||
export const DISPLAY_ERROR_KINDS = Object.freeze({
|
||||
SLIDE_DECODE_FAILED: "slide_decode_failed",
|
||||
ASSET_FETCH_FAILED: "asset_fetch_failed",
|
||||
CACHE_WRITE_FAILED: "cache_write_failed",
|
||||
MANIFEST_INVALID: "manifest_invalid",
|
||||
});
|
||||
|
||||
const KNOWN_KINDS = new Set(Object.values(DISPLAY_ERROR_KINDS));
|
||||
const MAX_MESSAGE_LENGTH = 240;
|
||||
// display_clients.last_error is TEXT, so the serialized form stays well inside
|
||||
// the server's own bounded-string validation.
|
||||
export const MAX_SERIALIZED_LENGTH = 480;
|
||||
|
||||
function boundedText(value, limit) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}…` : trimmed;
|
||||
}
|
||||
|
||||
export function createDisplayError(kind, details = {}) {
|
||||
if (!KNOWN_KINDS.has(kind)) {
|
||||
throw new TypeError(`Unknown display error kind: ${kind}`);
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
assetId: boundedText(details.assetId, 64),
|
||||
entryId: boundedText(details.entryId, 64),
|
||||
message: boundedText(details.message, MAX_MESSAGE_LENGTH) || kind,
|
||||
at: Number.isSafeInteger(details.at) ? details.at : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// The server accepts a string or a structured object; sending JSON keeps the
|
||||
// display's error machine-readable without needing a schema migration.
|
||||
export function serializeDisplayError(error) {
|
||||
if (!error) return null;
|
||||
const serialized = JSON.stringify({
|
||||
kind: error.kind,
|
||||
assetId: error.assetId ?? null,
|
||||
entryId: error.entryId ?? null,
|
||||
message: error.message,
|
||||
at: error.at,
|
||||
});
|
||||
if (serialized.length <= MAX_SERIALIZED_LENGTH) return serialized;
|
||||
return JSON.stringify({
|
||||
kind: error.kind,
|
||||
assetId: error.assetId ?? null,
|
||||
entryId: error.entryId ?? null,
|
||||
message: boundedText(error.message, 80) || error.kind,
|
||||
at: error.at,
|
||||
});
|
||||
}
|
||||
|
||||
// An error describes a specific asset attempt. Once that asset renders, or the
|
||||
// entry leaves the authoritative manifest, the condition is genuinely gone.
|
||||
// A mere passage of time never clears a problem that still exists.
|
||||
export function shouldClearError(error, { renderedAssetId, manifestAssetIds }) {
|
||||
if (!error) return false;
|
||||
if (!error.assetId) return false;
|
||||
if (error.assetId === renderedAssetId) return true;
|
||||
if (Array.isArray(manifestAssetIds) && !manifestAssetIds.includes(error.assetId)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// A true 16:9 slide fills the frame. Everything else is shown whole on a
|
||||
// background. The decision comes from the manifest's real pixel dimensions,
|
||||
// never from a filename or a resolution label.
|
||||
export const ASPECT_16_9 = 16 / 9;
|
||||
export const ASPECT_TOLERANCE = 0.02;
|
||||
export const DEFAULT_BLUR_PX = 20;
|
||||
export const DARKENING_OPACITY = 0.25;
|
||||
|
||||
export function isSixteenByNine(width, height) {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
|
||||
if (width <= 0 || height <= 0) return false;
|
||||
return Math.abs(width / height - ASPECT_16_9) <= ASPECT_TOLERANCE;
|
||||
}
|
||||
|
||||
function normalizeBackgroundMode(mode) {
|
||||
return mode === "solid" || mode === "black" || mode === "blurred"
|
||||
? mode
|
||||
: "blurred";
|
||||
}
|
||||
|
||||
function normalizeColor(value) {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim())
|
||||
? value.trim()
|
||||
: "#000000";
|
||||
}
|
||||
|
||||
function normalizeBlur(value) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_BLUR_PX;
|
||||
}
|
||||
|
||||
// Resolves one authoritative target into everything a scene layer needs. The
|
||||
// server has already resolved entry overrides against global defaults, so this
|
||||
// only decides how those effective values are painted.
|
||||
export function resolveScenePresentation(target) {
|
||||
const media = target?.asset?.media || {};
|
||||
const presentation = target?.effectivePresentation || {};
|
||||
const fills = isSixteenByNine(media.width, media.height);
|
||||
const backgroundMode = normalizeBackgroundMode(presentation.backgroundMode);
|
||||
const darkened = Boolean(presentation.backgroundDarkened);
|
||||
|
||||
if (fills) {
|
||||
// A correct 16:9 slide covers the whole frame on its own: no blurred
|
||||
// duplicate, no pillarbox, and no floating-card shadow.
|
||||
return {
|
||||
fit: "fill",
|
||||
backgroundMode: "none",
|
||||
backgroundColor: "#000000",
|
||||
backgroundImage: false,
|
||||
blurPx: 0,
|
||||
darkened: false,
|
||||
darkenOpacity: 0,
|
||||
shadow: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fit: "contain",
|
||||
backgroundMode,
|
||||
backgroundColor: normalizeColor(presentation.backgroundColor),
|
||||
backgroundImage: backgroundMode === "blurred",
|
||||
blurPx: backgroundMode === "blurred" ? normalizeBlur(presentation.blurPx) : 0,
|
||||
darkened,
|
||||
darkenOpacity: darkened
|
||||
? Number.isFinite(presentation.backgroundDarkeningOpacity)
|
||||
? presentation.backgroundDarkeningOpacity
|
||||
: DARKENING_OPACITY
|
||||
: 0,
|
||||
shadow: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// One persistent full-screen scene layer.
|
||||
//
|
||||
// The v1 renderer recreated image elements per slide and cross-faded the
|
||||
// outgoing frame to transparent, which let the black root show through the
|
||||
// composite. These layers are created once and never rebuilt; only their
|
||||
// contents and opacity change, and an outgoing layer is never faded out.
|
||||
|
||||
export function createSceneLayer(root) {
|
||||
const background = root.querySelector(".scene-background");
|
||||
const backgroundImage = root.querySelector(".scene-background-image");
|
||||
const darken = root.querySelector(".scene-darken");
|
||||
const foreground = root.querySelector(".scene-foreground");
|
||||
|
||||
let sceneKey = null;
|
||||
let foregroundUrl = null;
|
||||
let backgroundUrl = null;
|
||||
|
||||
function setStacking(zIndex) {
|
||||
root.style.zIndex = String(zIndex);
|
||||
}
|
||||
|
||||
function setOpacity(value, transitionMs) {
|
||||
root.style.transitionDuration = `${Math.max(0, transitionMs)}ms`;
|
||||
root.style.opacity = String(value);
|
||||
root.dataset.visible = value >= 1 ? "true" : "false";
|
||||
}
|
||||
|
||||
function forceReflow() {
|
||||
// Guarantees the browser observes opacity 0 before the transition starts,
|
||||
// so the incoming layer actually animates instead of snapping.
|
||||
void root.offsetHeight;
|
||||
}
|
||||
|
||||
// Applies a fully prepared scene and resolves only once the browser can
|
||||
// actually paint the image. A freshly assigned src is never treated as ready.
|
||||
async function prepare(scene) {
|
||||
root.dataset.sceneKey = scene.key;
|
||||
root.dataset.fit = scene.presentation.fit;
|
||||
root.dataset.backgroundMode = scene.presentation.backgroundMode;
|
||||
root.dataset.darkened = scene.presentation.darkened ? "true" : "false";
|
||||
root.dataset.shadow = scene.presentation.shadow ? "true" : "false";
|
||||
|
||||
background.style.backgroundColor =
|
||||
scene.presentation.backgroundMode === "solid"
|
||||
? scene.presentation.backgroundColor
|
||||
: "#000000";
|
||||
|
||||
if (scene.presentation.backgroundImage) {
|
||||
backgroundImage.style.display = "block";
|
||||
backgroundImage.style.filter = `blur(${scene.presentation.blurPx}px)`;
|
||||
if (backgroundImage.getAttribute("src") !== scene.objectUrl) {
|
||||
backgroundImage.setAttribute("src", scene.objectUrl);
|
||||
}
|
||||
} else {
|
||||
backgroundImage.style.display = "none";
|
||||
backgroundImage.removeAttribute("src");
|
||||
}
|
||||
|
||||
darken.style.opacity = String(scene.presentation.darkenOpacity);
|
||||
foreground.style.objectFit =
|
||||
scene.presentation.fit === "fill" ? "cover" : "contain";
|
||||
foreground.style.filter = scene.presentation.shadow
|
||||
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
|
||||
: "none";
|
||||
|
||||
if (foreground.getAttribute("src") !== scene.objectUrl) {
|
||||
foreground.setAttribute("src", scene.objectUrl);
|
||||
}
|
||||
// The element that will actually be composited must be decodable, not just
|
||||
// present in the DOM.
|
||||
await foreground.decode();
|
||||
if (scene.presentation.backgroundImage) {
|
||||
await backgroundImage.decode();
|
||||
}
|
||||
|
||||
sceneKey = scene.key;
|
||||
foregroundUrl = scene.mediaUrl;
|
||||
backgroundUrl = scene.presentation.backgroundImage ? scene.mediaUrl : null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
root.dataset.sceneKey = "";
|
||||
foreground.removeAttribute("src");
|
||||
backgroundImage.removeAttribute("src");
|
||||
sceneKey = null;
|
||||
foregroundUrl = null;
|
||||
backgroundUrl = null;
|
||||
}
|
||||
|
||||
return {
|
||||
clear,
|
||||
element: root,
|
||||
forceReflow,
|
||||
get backgroundUrl() {
|
||||
return backgroundUrl;
|
||||
},
|
||||
get mediaUrl() {
|
||||
return foregroundUrl;
|
||||
},
|
||||
get sceneKey() {
|
||||
return sceneKey;
|
||||
},
|
||||
prepare,
|
||||
setOpacity,
|
||||
setStacking,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Snapshot acceptance and local rotation prediction.
|
||||
//
|
||||
// The server is always authoritative. These helpers only decide whether an
|
||||
// arriving snapshot is newer than what is already accepted, and what the
|
||||
// display would expect to happen next so transitions stay smooth between
|
||||
// packets.
|
||||
|
||||
export function manifestSignature(snapshot) {
|
||||
const entries = snapshot?.manifest?.entries || [];
|
||||
return [
|
||||
snapshot?.manifest?.playlistId ?? "none",
|
||||
snapshot?.manifest?.playlistRevision ?? 0,
|
||||
entries
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.id,
|
||||
entry.position,
|
||||
entry.assetId,
|
||||
entry.asset?.media?.sha256 ?? "",
|
||||
entry.effectiveDurationMs,
|
||||
entry.scheduleStartAt ?? "",
|
||||
entry.scheduleEndAt ?? "",
|
||||
entry.effectivePresentation?.backgroundMode ?? "",
|
||||
entry.effectivePresentation?.backgroundColor ?? "",
|
||||
entry.effectivePresentation?.blurPx ?? "",
|
||||
entry.effectivePresentation?.backgroundDarkened ? 1 : 0,
|
||||
].join(":"),
|
||||
)
|
||||
.join("|"),
|
||||
].join("#");
|
||||
}
|
||||
|
||||
// Playback revision alone is not enough. A playlist edit, a settings change, or
|
||||
// a schedule boundary can produce a manifest-relevant snapshot while the
|
||||
// playback revision legitimately stays the same, so an equal revision is never
|
||||
// treated as a duplicate on its own.
|
||||
export function classifySnapshot(accepted, next) {
|
||||
if (!next || typeof next.playbackRevision !== "number") {
|
||||
return { accept: false, reason: "invalid" };
|
||||
}
|
||||
if (!accepted) return { accept: true, reason: "first" };
|
||||
|
||||
if (next.playbackRevision < accepted.playbackRevision) {
|
||||
return { accept: false, reason: "stale_playback_revision" };
|
||||
}
|
||||
if (next.playbackRevision > accepted.playbackRevision) {
|
||||
return { accept: true, reason: "playback_revision" };
|
||||
}
|
||||
if ((next.settingsRevision ?? 0) !== (accepted.settingsRevision ?? 0)) {
|
||||
return { accept: true, reason: "settings_revision" };
|
||||
}
|
||||
if (manifestSignature(next) !== manifestSignature(accepted)) {
|
||||
return { accept: true, reason: "manifest" };
|
||||
}
|
||||
if (targetKey(next) !== targetKey(accepted)) {
|
||||
return { accept: true, reason: "target" };
|
||||
}
|
||||
if (
|
||||
next.mode !== accepted.mode ||
|
||||
next.transportState !== accepted.transportState ||
|
||||
(next.pendingPlaylistId ?? null) !== (accepted.pendingPlaylistId ?? null)
|
||||
) {
|
||||
return { accept: true, reason: "transport" };
|
||||
}
|
||||
// Identical authoritative state: still worth re-anchoring the clock, but no
|
||||
// visual work is implied.
|
||||
return { accept: true, reason: "reanchor", unchanged: true };
|
||||
}
|
||||
|
||||
export function targetKey(snapshot) {
|
||||
if (!snapshot) return "none";
|
||||
if (snapshot.mode === "off") return "black:mode_off";
|
||||
if (snapshot.staticTarget) {
|
||||
return `static:${snapshot.staticTarget.assetId}:${
|
||||
snapshot.staticTarget.asset?.media?.sha256 ?? ""
|
||||
}`;
|
||||
}
|
||||
if (snapshot.normalTarget) {
|
||||
return `normal:${snapshot.normalTarget.id}:${
|
||||
snapshot.normalTarget.asset?.media?.sha256 ?? ""
|
||||
}`;
|
||||
}
|
||||
return "black:no_playable_content";
|
||||
}
|
||||
|
||||
// Schedules are [start, end): active exactly at start, inactive exactly at end.
|
||||
export function entryPlayableAt(entry, at) {
|
||||
if (!entry) return false;
|
||||
if (entry.scheduleStartAt != null && at < entry.scheduleStartAt) return false;
|
||||
if (entry.scheduleEndAt != null && at >= entry.scheduleEndAt) return false;
|
||||
if (entry.expirationAt != null && at >= entry.expirationAt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function orderedEntries(manifest) {
|
||||
return [...(manifest?.entries || [])].sort(
|
||||
(left, right) => left.position - right.position,
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the server's adjacent-playable rotation so a predicted transition
|
||||
// lands on the same entry the server will later confirm.
|
||||
export function predictNextEntry(manifest, currentEntryId, at, unavailableIds = []) {
|
||||
const entries = orderedEntries(manifest);
|
||||
if (entries.length === 0) return null;
|
||||
const usable = (entry) =>
|
||||
entryPlayableAt(entry, at) && !unavailableIds.includes(entry.id);
|
||||
const index = entries.findIndex((entry) => entry.id === currentEntryId);
|
||||
if (index < 0) return entries.find(usable) || null;
|
||||
for (let offset = 1; offset <= entries.length; offset += 1) {
|
||||
const candidate = entries[(index + offset) % entries.length];
|
||||
if (usable(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A queued activation means the very next boundary belongs to a playlist the
|
||||
// display may not have yet. Predicting another slide from the old playlist
|
||||
// would briefly show the wrong content, so prediction is withheld instead.
|
||||
export function canPredictLocally(snapshot) {
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.transportState !== "playing") return false;
|
||||
if (snapshot.staticTarget) return false;
|
||||
if (!snapshot.normalTarget) return false;
|
||||
if (typeof snapshot.timeline?.nextSlideAt !== "number") return false;
|
||||
if (snapshot.pendingPlaylistId) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Neutral global baseline. Signage-specific fullscreen rules are scoped to the
|
||||
display route in display.css so the future dashboard is not constrained. */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -7,12 +9,8 @@ body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: block;
|
||||
}
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
+21
-9
@@ -1,10 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
import DisplayApp from "./display/DisplayApp.jsx";
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
|
||||
|
||||
// The service worker only backs the signage app shell. Slide bytes are owned by
|
||||
// the explicit Cache Storage asset manager, never by the worker.
|
||||
if (isDisplayRoute && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/display-sw.js", { scope: "/" }).catch(() => {
|
||||
// A kiosk without a worker still runs; it just loses offline app-shell
|
||||
// startup until the next successful registration.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <App />}</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user