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:
@@ -361,6 +361,128 @@ become Home Assistant notifications. Structured display-side errors such as
|
|||||||
decode failures arrive with the Phase 5 renderer, which will report them through
|
decode failures arrive with the Phase 5 renderer, which will report them through
|
||||||
this same field and event.
|
this same field and event.
|
||||||
|
|
||||||
|
## Signage display
|
||||||
|
|
||||||
|
`/display` is the signage output and `/` is reserved for the future management
|
||||||
|
dashboard. Both are served by the same single-page build, so `/display` survives
|
||||||
|
direct navigation and refresh through the existing Nginx SPA fallback. The
|
||||||
|
display stays a Chromium kiosk pointed at a web application: v2 makes that
|
||||||
|
application reliable rather than replacing it with a native client.
|
||||||
|
|
||||||
|
Chromium should open `/display` in kiosk mode against an origin the browser
|
||||||
|
treats as secure — `http://localhost`, a private hostname over HTTPS, or the
|
||||||
|
equivalent. Cache Storage, Service Workers, and therefore offline behaviour are
|
||||||
|
unavailable on an insecure origin, and the display degrades to a network-only
|
||||||
|
renderer without them.
|
||||||
|
|
||||||
|
The old v1 kiosk flags that disabled the browser cache are no longer required
|
||||||
|
and should be removed at cutover. They existed because v1 reused mutable slide
|
||||||
|
URLs; v2 media URLs are content-addressed, so a changed image always arrives
|
||||||
|
under a new URL and stale bytes are impossible. Those flags now actively
|
||||||
|
interfere with the Service Worker and Cache Storage the display depends on.
|
||||||
|
|
||||||
|
### Transition guarantee
|
||||||
|
|
||||||
|
The renderer owns two permanent full-screen scene layers. Each layer is a
|
||||||
|
complete scene — background, optional darkening overlay, and foreground image —
|
||||||
|
and neither layer is ever recreated; only its contents and opacity change.
|
||||||
|
|
||||||
|
A transition prepares the hidden layer completely, decodes the incoming image
|
||||||
|
through `Image.decode()` on the element that will actually be composited, then
|
||||||
|
raises that layer above the current one at opacity 0 and fades it in. The
|
||||||
|
outgoing layer stays fully opaque underneath for the whole transition and is
|
||||||
|
never faded out. This is the specific v1 defect that produced black frames: two
|
||||||
|
simultaneous fades let the black root show through the composite.
|
||||||
|
|
||||||
|
If an incoming asset cannot be fetched or decoded, the last good frame simply
|
||||||
|
stays on screen. The entry is marked locally unavailable, a structured error is
|
||||||
|
recorded for telemetry, and rotation continues past it rather than retrying it
|
||||||
|
in a loop. Every asynchronous load and transition carries a generation token, so
|
||||||
|
work that completes after authoritative state has moved on is discarded instead
|
||||||
|
of overwriting the newer target.
|
||||||
|
|
||||||
|
While connected, the display schedules the expected next transition locally from
|
||||||
|
the authoritative timeline and the cached manifest, so transitions stay smooth
|
||||||
|
between packets. The server always wins any disagreement. When an activation is
|
||||||
|
pending for a playlist the display does not yet hold, local prediction is
|
||||||
|
withheld entirely: holding the current frame a little longer is better than
|
||||||
|
briefly showing the wrong slide.
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
|
||||||
|
A slide whose real pixel dimensions are 16:9 within a small tolerance fills the
|
||||||
|
frame: no blurred duplicate, no pillarbox, and no foreground shadow. Anything
|
||||||
|
else is shown whole and centred with `object-fit: contain`, never cropped, over
|
||||||
|
its configured background and with a subtle drop shadow.
|
||||||
|
|
||||||
|
Backgrounds follow the server's already-resolved effective values. `blurred`
|
||||||
|
reuses the same slide image, covered and centred, blurred by the global setting
|
||||||
|
(20px by default) and overscanned so the blur cannot reveal an edge. `solid`
|
||||||
|
fills with the configured colour and `black` fills with black. Background
|
||||||
|
darkening is boolean, resolved by the server from the global default and the
|
||||||
|
entry's inherit/off/on override, and applies a fixed overlay of about 0.25 to
|
||||||
|
the background only — never to the readable foreground. Blur is applied at
|
||||||
|
render time and is never baked into a stored derivative.
|
||||||
|
|
||||||
|
Static targets have no playlist entry, so they use the global presentation
|
||||||
|
defaults the snapshot already carries, through the same renderer and the same
|
||||||
|
transition path.
|
||||||
|
|
||||||
|
`mode=off` is a topmost black gate that covers the output immediately. The
|
||||||
|
renderer keeps tracking the timeline, caching, and updating its current target
|
||||||
|
underneath, so switching the mode back on reveals the synchronized scene without
|
||||||
|
restarting the playlist.
|
||||||
|
|
||||||
|
### Explicit asset cache
|
||||||
|
|
||||||
|
Slide bytes are stored explicitly in Cache Storage under
|
||||||
|
`announcements-display-assets-v1`, not left to ordinary HTTP caching. The asset
|
||||||
|
manager checks the cache, fetches only on a miss, validates the response, stores
|
||||||
|
a clone, and returns a blob that is decoded before it is ever presented. Object
|
||||||
|
URLs are reference counted, and bytes are never revoked while a scene layer is
|
||||||
|
still displaying them.
|
||||||
|
|
||||||
|
A new manifest never destroys the working one. Incoming assets are fetched and
|
||||||
|
validated first and the manifest is promoted once it is usable; one unfetchable
|
||||||
|
asset marks only itself unavailable rather than rejecting the whole playlist.
|
||||||
|
After promotion, anything not referenced by the promoted manifest, the current
|
||||||
|
static target, or a live scene layer is pruned. Quota pressure triggers a prune
|
||||||
|
and one retry before a bounded error is reported.
|
||||||
|
|
||||||
|
### Persistence and the app shell
|
||||||
|
|
||||||
|
A small IndexedDB database (`announcements-display`, version 1, one object
|
||||||
|
store, explicit upgrade handling) holds the persistent client ID, the
|
||||||
|
last-known-good manifest with the revisions needed to interpret it, the last
|
||||||
|
authoritative timing anchor, mode and transport state, and the most recent
|
||||||
|
structured error. Malformed or superseded records are discarded on read so the
|
||||||
|
display starts cleanly from the server instead of trusting an unreadable
|
||||||
|
snapshot. The client ID is generated once and reused across refresh, reconnect,
|
||||||
|
and Chromium restart; a socket ID is never used as display identity.
|
||||||
|
|
||||||
|
`display-sw.js` caches only the `/display` application shell. Navigations are
|
||||||
|
network-first so an online kiosk always receives the newest application, with
|
||||||
|
the cached shell as the offline fallback, and content-hashed Vite assets are
|
||||||
|
cache-first. The worker explicitly bypasses `/media`, `/api`, `/socket.io`, and
|
||||||
|
`/health`: slide bytes belong to the asset manager and live server state must
|
||||||
|
never be answered from a cache. It calls `skipWaiting` and `clients.claim`, so a
|
||||||
|
kiosk cannot be trapped on a stale version.
|
||||||
|
|
||||||
|
### Heartbeat and errors
|
||||||
|
|
||||||
|
The display emits `display:heartbeat` about every 12 seconds with its persistent
|
||||||
|
client ID, the active playlist and revisions it is actually rendering, the
|
||||||
|
current entry and asset, its true cached and locally playable asset counts, and
|
||||||
|
its most recent error. Telemetry is read-only and never mutates playback.
|
||||||
|
|
||||||
|
Errors are structured internally as `slide_decode_failed`, `asset_fetch_failed`,
|
||||||
|
`cache_write_failed`, or `manifest_invalid`, each with the relevant asset or
|
||||||
|
entry, a bounded message, and a timestamp. They are sent as bounded JSON, which
|
||||||
|
the server's existing string validation already accepts, so no schema migration
|
||||||
|
was required; `/api/displays` parses them back into an object. An error clears
|
||||||
|
when the failing asset renders successfully or leaves the manifest — never
|
||||||
|
merely because time passed.
|
||||||
|
|
||||||
## Storage and health
|
## Storage and health
|
||||||
|
|
||||||
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||||||
|
|||||||
@@ -22,3 +22,10 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Playwright browser test artifacts
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
debug.mjs
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ensureFixtures } from "./fixtures.js";
|
||||||
|
import {
|
||||||
|
activatePlaylist,
|
||||||
|
api,
|
||||||
|
displayDiagnostics,
|
||||||
|
resetPlayback,
|
||||||
|
visibleScene,
|
||||||
|
waitForFirstSlide,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
// The outgoing layer deliberately stays opaque during and after a transition,
|
||||||
|
// so the layer actually on screen is the opaque one with the highest stacking
|
||||||
|
// order — never simply the first opaque layer in DOM order.
|
||||||
|
function readTopLayer(page, extract) {
|
||||||
|
return page.evaluate((source) => {
|
||||||
|
const layer = [...document.querySelectorAll(".scene")]
|
||||||
|
.map((element) => ({ element, style: getComputedStyle(element) }))
|
||||||
|
.filter((entry) => Number(entry.style.opacity) > 0.99)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(right.style.zIndex || 0) - Number(left.style.zIndex || 0),
|
||||||
|
)[0]?.element;
|
||||||
|
if (!layer) return null;
|
||||||
|
return new Function("layer", `return (${source})(layer);`)(layer);
|
||||||
|
}, extract.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await ensureFixtures(request);
|
||||||
|
await resetPlayback(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the display loads, connects, and shows the authoritative slide", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
const { entries } = await activatePlaylist(request, "Basic", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
const playback = await api(request, "/api/playback");
|
||||||
|
const diagnostics = await displayDiagnostics(page);
|
||||||
|
expect(diagnostics.renderedEntryId).toBe(playback.currentEntryId);
|
||||||
|
expect(diagnostics.renderedEntryId).toBe(entries[0].id);
|
||||||
|
expect(diagnostics.renderedAssetId).toBe(library["wide-a"].id);
|
||||||
|
expect(diagnostics.acceptedRevision).toBe(playback.playbackRevision);
|
||||||
|
|
||||||
|
// Signage output only: no dashboard chrome may appear over the slides.
|
||||||
|
await expect(page.locator(".placeholder")).toHaveCount(0);
|
||||||
|
await expect(page.locator("h1")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("display-root")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
|
||||||
|
expect(await page.evaluate(() => document.body.innerText.trim())).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a true 16:9 slide fills the frame with no blurred duplicate", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Wide", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
const scene = await visibleScene(page);
|
||||||
|
expect(scene.fit).toBe("fill");
|
||||||
|
expect(scene.backgroundMode).toBe("none");
|
||||||
|
expect(scene.shadow).toBe("false");
|
||||||
|
|
||||||
|
const painted = await readTopLayer(page, (layer) => {
|
||||||
|
const background = layer.querySelector(".scene-background-image");
|
||||||
|
const foreground = layer.querySelector(".scene-foreground");
|
||||||
|
return {
|
||||||
|
backgroundDisplay: getComputedStyle(background).display,
|
||||||
|
foregroundFit: getComputedStyle(foreground).objectFit,
|
||||||
|
foregroundFilter: getComputedStyle(foreground).filter,
|
||||||
|
foregroundBox: foreground.getBoundingClientRect().toJSON(),
|
||||||
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(painted.backgroundDisplay).toBe("none");
|
||||||
|
expect(painted.foregroundFit).toBe("cover");
|
||||||
|
expect(painted.foregroundFilter).toBe("none");
|
||||||
|
expect(painted.foregroundBox.width).toBeCloseTo(painted.viewport.width, 0);
|
||||||
|
expect(painted.foregroundBox.height).toBeCloseTo(painted.viewport.height, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fixture of ["portrait", "square"]) {
|
||||||
|
test(`a ${fixture} slide is contained over a blurred background`, async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, `Blurred ${fixture}`, [
|
||||||
|
{
|
||||||
|
assetId: library[fixture].id,
|
||||||
|
durationOverrideMs: 60_000,
|
||||||
|
backgroundModeOverride: "blurred",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
const scene = await visibleScene(page);
|
||||||
|
expect(scene.fit).toBe("contain");
|
||||||
|
expect(scene.backgroundMode).toBe("blurred");
|
||||||
|
expect(scene.shadow).toBe("true");
|
||||||
|
|
||||||
|
const painted = await readTopLayer(page, (layer) => {
|
||||||
|
const background = layer.querySelector(".scene-background-image");
|
||||||
|
const foreground = layer.querySelector(".scene-foreground");
|
||||||
|
const backgroundStyle = getComputedStyle(background);
|
||||||
|
return {
|
||||||
|
backgroundDisplay: backgroundStyle.display,
|
||||||
|
backgroundFit: backgroundStyle.objectFit,
|
||||||
|
backgroundFilter: backgroundStyle.filter,
|
||||||
|
backgroundTransform: backgroundStyle.transform,
|
||||||
|
backgroundBox: background.getBoundingClientRect().toJSON(),
|
||||||
|
foregroundFit: getComputedStyle(foreground).objectFit,
|
||||||
|
foregroundFilter: getComputedStyle(foreground).filter,
|
||||||
|
foregroundBox: foreground.getBoundingClientRect().toJSON(),
|
||||||
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(painted.backgroundDisplay).toBe("block");
|
||||||
|
expect(painted.backgroundFit).toBe("cover");
|
||||||
|
expect(painted.backgroundFilter).toBe("blur(20px)");
|
||||||
|
expect(painted.backgroundTransform).not.toBe("none");
|
||||||
|
expect(painted.backgroundBox.width).toBeGreaterThanOrEqual(painted.viewport.width);
|
||||||
|
expect(painted.backgroundBox.height).toBeGreaterThanOrEqual(painted.viewport.height);
|
||||||
|
|
||||||
|
expect(painted.foregroundFit).toBe("contain");
|
||||||
|
expect(painted.foregroundFilter).toContain("drop-shadow");
|
||||||
|
// Contain must never crop: the whole slide fits inside the viewport.
|
||||||
|
expect(painted.foregroundBox.width).toBeLessThanOrEqual(painted.viewport.width + 1);
|
||||||
|
expect(painted.foregroundBox.height).toBeLessThanOrEqual(painted.viewport.height + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("solid, black, and darkened backgrounds use the effective server values", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
const { playlist, entries } = await activatePlaylist(request, "Backgrounds", [
|
||||||
|
{
|
||||||
|
assetId: library.portrait.id,
|
||||||
|
durationOverrideMs: 60_000,
|
||||||
|
backgroundModeOverride: "solid",
|
||||||
|
backgroundColorOverride: "#123456",
|
||||||
|
backgroundDarkenedOverride: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
const readBackground = () =>
|
||||||
|
readTopLayer(page, (layer) => ({
|
||||||
|
mode: layer.dataset.backgroundMode,
|
||||||
|
darkened: layer.dataset.darkened,
|
||||||
|
color: getComputedStyle(layer.querySelector(".scene-background")).backgroundColor,
|
||||||
|
darkenOpacity: Number(
|
||||||
|
getComputedStyle(layer.querySelector(".scene-darken")).opacity,
|
||||||
|
),
|
||||||
|
foregroundOpacity: Number(
|
||||||
|
getComputedStyle(layer.querySelector(".scene-foreground")).opacity,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect.poll(async () => (await readBackground()).mode).toBe("solid");
|
||||||
|
let background = await readBackground();
|
||||||
|
expect(background.color).toBe("rgb(18, 52, 86)");
|
||||||
|
expect(background.darkened).toBe("false");
|
||||||
|
expect(background.darkenOpacity).toBe(0);
|
||||||
|
|
||||||
|
// Darkening is boolean and applies to the background only.
|
||||||
|
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
data: { backgroundDarkenedOverride: true },
|
||||||
|
});
|
||||||
|
await expect.poll(async () => (await readBackground()).darkened).toBe("true");
|
||||||
|
background = await readBackground();
|
||||||
|
expect(background.darkenOpacity).toBeCloseTo(0.25, 2);
|
||||||
|
expect(background.foregroundOpacity, "the readable slide is never darkened").toBe(1);
|
||||||
|
|
||||||
|
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
data: { backgroundModeOverride: "black" },
|
||||||
|
});
|
||||||
|
await expect.poll(async () => (await readBackground()).mode).toBe("black");
|
||||||
|
expect((await readBackground()).color).toBe("rgb(0, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the browser never receives private storage details", async ({ page, request }) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Privacy", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const urls = [];
|
||||||
|
page.on("request", (item) => urls.push(item.url()));
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
const snapshot = await api(request, "/api/playback");
|
||||||
|
const serialized = JSON.stringify(snapshot) + urls.join(" ");
|
||||||
|
expect(serialized).not.toMatch(
|
||||||
|
/seaweed|8333|private\/|object_key|objectKey|bucket|secret|accessKey/i,
|
||||||
|
);
|
||||||
|
for (const url of urls.filter((item) => item.includes("/media/"))) {
|
||||||
|
expect(url).toMatch(/\/media\/assets\/[0-9a-f-]{36}\/display\/[0-9a-f]{64}\.webp$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ensureFixtures } from "./fixtures.js";
|
||||||
|
import {
|
||||||
|
activatePlaylist,
|
||||||
|
api,
|
||||||
|
displayDiagnostics,
|
||||||
|
resetPlayback,
|
||||||
|
waitForFirstSlide,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
const ASSET_CACHE = "announcements-display-assets-v1";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await ensureFixtures(request);
|
||||||
|
await resetPlayback(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cachedPaths(page, cacheName = ASSET_CACHE) {
|
||||||
|
return page.evaluate(async (name) => {
|
||||||
|
if (!(await caches.has(name))) return [];
|
||||||
|
const cache = await caches.open(name);
|
||||||
|
return (await cache.keys()).map((request) => new URL(request.url).pathname);
|
||||||
|
}, cacheName);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("slide bytes are stored in the explicit cache and reused on the next load", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Cache", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
await expect.poll(async () => (await cachedPaths(page)).length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const stored = await cachedPaths(page);
|
||||||
|
expect(stored.some((path) => path.includes(library["wide-a"].id))).toBe(true);
|
||||||
|
expect(stored.every((path) => /^\/media\/assets\//.test(path))).toBe(true);
|
||||||
|
|
||||||
|
// Second visit must be served from the explicit cache, not the network.
|
||||||
|
const mediaRequests = [];
|
||||||
|
await page.reload();
|
||||||
|
page.on("request", (item) => {
|
||||||
|
if (item.url().includes("/media/")) mediaRequests.push(item.url());
|
||||||
|
});
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
expect(
|
||||||
|
mediaRequests.filter((url) => url.includes(library["wide-a"].id)).length,
|
||||||
|
"a cached asset must not be refetched",
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
const diagnostics = await displayDiagnostics(page);
|
||||||
|
expect(diagnostics.activeObjectUrls).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
// The heartbeat reports the true cached count.
|
||||||
|
const beat = await page.evaluate(() => window.__display.heartbeat());
|
||||||
|
expect(beat.cachedAssetCount).toBe((await cachedPaths(page)).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new immutable version is fetched separately and stale bytes are pruned", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Immutable", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const first = await cachedPaths(page);
|
||||||
|
const firstUrl = first.find((path) => path.includes(library["wide-a"].id));
|
||||||
|
expect(firstUrl).toMatch(/\/display\/[0-9a-f]{64}\.webp$/);
|
||||||
|
|
||||||
|
// Switching playlists retains only what the promoted manifest still needs.
|
||||||
|
await activatePlaylist(request, "Immutable next", [
|
||||||
|
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
|
||||||
|
.toBe(library["wide-c"].id);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await cachedPaths(page)).some((path) => path.includes(library["wide-c"].id)))
|
||||||
|
.toBe(true);
|
||||||
|
const retained = await cachedPaths(page);
|
||||||
|
expect(
|
||||||
|
retained.some((path) => path.includes(library["wide-c"].id)),
|
||||||
|
"the active slide is never pruned",
|
||||||
|
).toBe(true);
|
||||||
|
expect(retained.length).toBeLessThanOrEqual(first.length + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the persistent client identity and last known good state survive a refresh", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Persistence", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const firstClientId = await page.evaluate(() => window.__display.clientId);
|
||||||
|
expect(firstClientId).toMatch(/[0-9a-f-]{8,}/);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
expect(await page.evaluate(() => window.__display.clientId)).toBe(firstClientId);
|
||||||
|
|
||||||
|
// A brand new page in the same origin is the same display, not a new one.
|
||||||
|
const other = await page.context().newPage();
|
||||||
|
await other.goto("/display");
|
||||||
|
await waitForFirstSlide(other);
|
||||||
|
expect(await other.evaluate(() => window.__display.clientId)).toBe(firstClientId);
|
||||||
|
await other.close();
|
||||||
|
|
||||||
|
const persisted = await page.evaluate(async () => {
|
||||||
|
const database = await new Promise((resolve, reject) => {
|
||||||
|
const open = indexedDB.open("announcements-display", 1);
|
||||||
|
open.onsuccess = () => resolve(open.result);
|
||||||
|
open.onerror = () => reject(open.error);
|
||||||
|
});
|
||||||
|
const value = await new Promise((resolve, reject) => {
|
||||||
|
const request = database
|
||||||
|
.transaction("state", "readonly")
|
||||||
|
.objectStore("state")
|
||||||
|
.get("lastKnownGood");
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
database.close();
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
expect(persisted.schemaVersion).toBe(1);
|
||||||
|
expect(persisted.playbackRevision).toBeGreaterThan(0);
|
||||||
|
expect(persisted.manifest.entries.length).toBeGreaterThan(0);
|
||||||
|
expect(persisted.timing.serverTime).toBeGreaterThan(0);
|
||||||
|
expect(persisted.mode).toBe("on");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed persisted state fails safe instead of breaking startup", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Malformed", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const clientId = await page.evaluate(() => window.__display.clientId);
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const database = await new Promise((resolve) => {
|
||||||
|
const open = indexedDB.open("announcements-display", 1);
|
||||||
|
open.onsuccess = () => resolve(open.result);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const transaction = database.transaction("state", "readwrite");
|
||||||
|
transaction.objectStore("state").put({ nonsense: true }, "lastKnownGood");
|
||||||
|
transaction.oncomplete = resolve;
|
||||||
|
});
|
||||||
|
database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
// A corrupt record is discarded, the identity is kept, and the display works.
|
||||||
|
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
|
||||||
|
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the service worker caches the app shell without touching slide bytes", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Worker", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||||
|
return Boolean(registration?.active);
|
||||||
|
}),
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => Boolean(navigator.serviceWorker.controller)))
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const shell = await cachedPaths(page, "announcements-display-shell-v1");
|
||||||
|
expect(shell.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(shell.some((path) => path === "/display" || path.startsWith("/assets/"))).toBe(true);
|
||||||
|
// The two caches stay strictly separate.
|
||||||
|
expect(shell.some((path) => path.startsWith("/media/"))).toBe(false);
|
||||||
|
const media = await cachedPaths(page);
|
||||||
|
expect(media.every((path) => path.startsWith("/media/"))).toBe(true);
|
||||||
|
|
||||||
|
// An online kiosk still receives the newest application on refresh.
|
||||||
|
const response = await page.reload();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the real display reports accurate telemetry to the server", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
const { playlist } = await activatePlaylist(request, "Heartbeat", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const clientId = await page.evaluate(() => window.__display.clientId);
|
||||||
|
await page.evaluate(() => window.__display.heartbeat());
|
||||||
|
|
||||||
|
const playback = await api(request, "/api/playback");
|
||||||
|
const detail = await api(request, `/api/playlists/${playlist.id}`);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const displays = await api(request, "/api/displays");
|
||||||
|
return displays.displays.find((display) => display.clientId === clientId) ?? null;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const displays = await api(request, "/api/displays");
|
||||||
|
const mine = displays.displays.find((display) => display.clientId === clientId);
|
||||||
|
expect(mine.status).toBe("online");
|
||||||
|
expect(mine.outputId).toBe("global");
|
||||||
|
expect(mine.activePlaylistId).toBe(playlist.id);
|
||||||
|
expect(mine.playlistRevision).toBe(detail.revision);
|
||||||
|
expect(mine.playbackRevision).toBe(playback.playbackRevision);
|
||||||
|
expect(mine.currentEntryId).toBe(playback.currentEntryId);
|
||||||
|
expect(mine.currentAssetId).toBe(playback.normalTarget.assetId);
|
||||||
|
expect(mine.cachedAssetCount).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(mine.playableAssetCount).toBe(2);
|
||||||
|
expect(mine.mostRecentError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the persistent client id survives closing and reopening the page", async ({
|
||||||
|
context,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Identity", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const first = await context.newPage();
|
||||||
|
await first.goto("/display");
|
||||||
|
await waitForFirstSlide(first);
|
||||||
|
const clientId = await first.evaluate(() => window.__display.clientId);
|
||||||
|
await first.close();
|
||||||
|
|
||||||
|
const second = await context.newPage();
|
||||||
|
await second.goto("/display");
|
||||||
|
await waitForFirstSlide(second);
|
||||||
|
expect(await second.evaluate(() => window.__display.clientId)).toBe(clientId);
|
||||||
|
|
||||||
|
const displays = await api(request, "/api/displays");
|
||||||
|
expect(
|
||||||
|
displays.displays.filter((display) => display.clientId === clientId).length,
|
||||||
|
"one physical display is one record",
|
||||||
|
).toBe(1);
|
||||||
|
await second.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ensureFixtures } from "./fixtures.js";
|
||||||
|
import {
|
||||||
|
activatePlaylist,
|
||||||
|
api,
|
||||||
|
displayDiagnostics,
|
||||||
|
resetPlayback,
|
||||||
|
visibleScene,
|
||||||
|
waitForFirstSlide,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await ensureFixtures(request);
|
||||||
|
await resetPlayback(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Primary acceptance test. It fails if anyone restores the v1 architecture that
|
||||||
|
// faded the outgoing frame to transparent over a black root.
|
||||||
|
test("the outgoing slide stays fully opaque until the incoming slide can be drawn", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "No black", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const slowUrl = `**/media/assets/${library["wide-b"].id}/**`;
|
||||||
|
let released = false;
|
||||||
|
await page.route(slowUrl, async (route) => {
|
||||||
|
// Hold the incoming slide's bytes well past its nominal transition instant.
|
||||||
|
while (!released) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const firstScene = await visibleScene(page);
|
||||||
|
expect(firstScene.sceneKey).toMatch(/^normal:/);
|
||||||
|
|
||||||
|
const before = await displayDiagnostics(page);
|
||||||
|
expect(before.renderedAssetId).toBe(library["wide-a"].id);
|
||||||
|
|
||||||
|
// Sample continuously across the boundary while B is deliberately stuck.
|
||||||
|
const samples = [];
|
||||||
|
const deadline = Date.now() + 9_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
samples.push(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
at: Date.now(),
|
||||||
|
gate: document.querySelector(".output-gate").dataset.active,
|
||||||
|
layers: [...document.querySelectorAll(".scene")].map((layer) => ({
|
||||||
|
key: layer.dataset.sceneKey || "",
|
||||||
|
opacity: Number(getComputedStyle(layer).opacity),
|
||||||
|
})),
|
||||||
|
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const opaque = sample.layers.filter((layer) => layer.opacity > 0.99);
|
||||||
|
expect(
|
||||||
|
opaque.length,
|
||||||
|
`black root was exposed at ${sample.at}: ${JSON.stringify(sample.layers)}`,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(sample.gate).toBe("false");
|
||||||
|
// The outgoing scene must never begin fading away.
|
||||||
|
expect(sample.rendered).toBe(library["wide-a"].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
released = true;
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||||
|
.toBe(library["wide-b"].id);
|
||||||
|
|
||||||
|
const afterScene = await visibleScene(page);
|
||||||
|
expect(afterScene.opacity).toBe(1);
|
||||||
|
// Exactly one layer is composited afterwards; the other is free for reuse.
|
||||||
|
const layerCount = await page.evaluate(
|
||||||
|
() => document.querySelectorAll(".scene").length,
|
||||||
|
);
|
||||||
|
expect(layerCount, "scene layers are persistent, never recreated").toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a late asset load never replaces a newer authoritative target", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Stale", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let releaseB = false;
|
||||||
|
await page.route(`**/media/assets/${library["wide-b"].id}/**`, async (route) => {
|
||||||
|
while (!releaseB) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
|
||||||
|
|
||||||
|
// Advance to B, whose bytes are stuck, then advance again to C.
|
||||||
|
await api(request, "/api/playback/next", { method: "POST" });
|
||||||
|
await page.waitForTimeout(1_500);
|
||||||
|
await api(request, "/api/playback/next", { method: "POST" });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||||
|
.toBe(library["wide-c"].id);
|
||||||
|
|
||||||
|
// B finally arrives long after it stopped being authoritative.
|
||||||
|
releaseB = true;
|
||||||
|
await page.waitForTimeout(4_000);
|
||||||
|
const diagnostics = await displayDiagnostics(page);
|
||||||
|
expect(
|
||||||
|
diagnostics.renderedAssetId,
|
||||||
|
"a stale load must never overwrite the newer target",
|
||||||
|
).toBe(library["wide-c"].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one unusable asset never blanks the screen or blocks the playlist", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Bad asset", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||||
|
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
|
||||||
|
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
await page.route(`**/media/assets/${library["wide-d"].id}/**`, async (route) => {
|
||||||
|
attempts += 1;
|
||||||
|
await route.fulfill({ status: 500, body: "broken" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
// Rotation continues across the broken slide without ever losing a frame.
|
||||||
|
const seen = new Set();
|
||||||
|
const deadline = Date.now() + 16_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||||
|
opaque: [...document.querySelectorAll(".scene")].filter(
|
||||||
|
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||||
|
).length,
|
||||||
|
}));
|
||||||
|
if (state.rendered) seen.add(state.rendered);
|
||||||
|
expect(state.opaque, "a good frame is always on screen").toBeGreaterThanOrEqual(1);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seen.has(library["wide-a"].id)).toBe(true);
|
||||||
|
expect(seen.has(library["wide-c"].id)).toBe(true);
|
||||||
|
expect(seen.has(library["wide-d"].id), "the broken slide never displays").toBe(false);
|
||||||
|
expect(attempts, "a bad asset must not be retried in a tight loop").toBeLessThan(25);
|
||||||
|
|
||||||
|
const diagnostics = await displayDiagnostics(page);
|
||||||
|
expect(diagnostics.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(diagnostics.mostRecentError).not.toBeNull();
|
||||||
|
expect(["asset_fetch_failed", "slide_decode_failed"]).toContain(
|
||||||
|
diagnostics.mostRecentError.kind,
|
||||||
|
);
|
||||||
|
expect(diagnostics.mostRecentError.assetId).toBe(library["wide-d"].id);
|
||||||
|
|
||||||
|
// The structured error reaches the server through the real heartbeat.
|
||||||
|
const clientId = await page.evaluate(() => window.__display.clientId);
|
||||||
|
await page.evaluate(() => window.__display.heartbeat());
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const displays = await api(request, "/api/displays");
|
||||||
|
const mine = displays.displays.find((display) => display.clientId === clientId);
|
||||||
|
return mine?.mostRecentError?.kind ?? null;
|
||||||
|
}, { timeout: 30_000 })
|
||||||
|
.toBe(diagnostics.mostRecentError.kind);
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { ensureFixtures } from "./fixtures.js";
|
||||||
|
import {
|
||||||
|
activatePlaylist,
|
||||||
|
api,
|
||||||
|
displayDiagnostics,
|
||||||
|
resetPlayback,
|
||||||
|
visibleScene,
|
||||||
|
waitForFirstSlide,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await ensureFixtures(request);
|
||||||
|
await resetPlayback(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode off gates the output to black while the renderer keeps tracking", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Mode", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 3_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
const first = await displayDiagnostics(page);
|
||||||
|
|
||||||
|
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
|
||||||
|
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => Number(getComputedStyle(document.querySelector(".output-gate")).opacity),
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
// The hidden renderer keeps advancing underneath the gate.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||||
|
.not.toBe(first.renderedAssetId);
|
||||||
|
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
|
||||||
|
|
||||||
|
const hidden = await displayDiagnostics(page);
|
||||||
|
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
|
||||||
|
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
|
||||||
|
|
||||||
|
const revealed = await displayDiagnostics(page);
|
||||||
|
const playback = await api(request, "/api/playback");
|
||||||
|
expect(revealed.renderedEntryId).toBe(playback.currentEntryId);
|
||||||
|
expect(
|
||||||
|
[hidden.renderedAssetId, revealed.renderedAssetId].includes(playback.normalTarget.assetId),
|
||||||
|
).toBe(true);
|
||||||
|
// Revealing must not restart the playlist from the first slide.
|
||||||
|
expect(await visibleScene(page)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a static hold renders a library-only asset and resume returns to rotation", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
// Long durations so normal rotation cannot advance underneath the
|
||||||
|
// assertions; this test is about the static hold, not about timing.
|
||||||
|
const { playlist } = await activatePlaylist(request, "Static", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 120_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 120_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const detail = await api(request, `/api/playlists/${playlist.id}`);
|
||||||
|
const inPlaylist = new Set(detail.entries.map((entry) => entry.assetId));
|
||||||
|
expect(inPlaylist.has(library.standalone.id)).toBe(false);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
|
||||||
|
await api(request, "/api/playback/static", {
|
||||||
|
method: "POST",
|
||||||
|
data: { assetId: library.standalone.id },
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||||
|
.toBe(library.standalone.id);
|
||||||
|
|
||||||
|
// The static asset was cached explicitly even though it is not in the manifest.
|
||||||
|
const cached = await page.evaluate(async () => {
|
||||||
|
const cache = await caches.open("announcements-display-assets-v1");
|
||||||
|
return (await cache.keys()).map((request) => new URL(request.url).pathname);
|
||||||
|
});
|
||||||
|
expect(cached.some((url) => url.includes(library.standalone.id))).toBe(true);
|
||||||
|
|
||||||
|
// Static holds indefinitely: the normal timeline does not advance locally.
|
||||||
|
await page.waitForTimeout(9_000);
|
||||||
|
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library.standalone.id);
|
||||||
|
expect((await visibleScene(page)).sceneKey).toContain("static:");
|
||||||
|
|
||||||
|
await api(request, "/api/playback/resume", { method: "POST" });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
|
||||||
|
.not.toBe(library.standalone.id);
|
||||||
|
|
||||||
|
// The display converges on whatever normal entry the server is authoritative
|
||||||
|
// about after the hold is released.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const [diagnostics, playback] = await Promise.all([
|
||||||
|
displayDiagnostics(page),
|
||||||
|
api(request, "/api/playback"),
|
||||||
|
]);
|
||||||
|
return diagnostics.renderedEntryId === playback.currentEntryId;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
expect((await visibleScene(page)).sceneKey).toMatch(/^normal:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a manifest or settings change is staged without reloading or blanking", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
const { playlist, entries } = await activatePlaylist(request, "Manifest", [
|
||||||
|
{ assetId: library.portrait.id, durationOverrideMs: 60_000, backgroundModeOverride: "blurred" },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__loadCount = (window.__loadCount || 0) + 1;
|
||||||
|
});
|
||||||
|
const before = await displayDiagnostics(page);
|
||||||
|
|
||||||
|
const watcher = page.evaluate(() => {
|
||||||
|
// Fail loudly if the root black is ever exposed while edits are applied.
|
||||||
|
window.__blackFrames = 0;
|
||||||
|
window.__watch = setInterval(() => {
|
||||||
|
const opaque = [...document.querySelectorAll(".scene")].filter(
|
||||||
|
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||||
|
);
|
||||||
|
if (opaque.length === 0) window.__blackFrames += 1;
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
await watcher;
|
||||||
|
|
||||||
|
await api(request, `/api/playlists/${playlist.id}/entries/order`, {
|
||||||
|
method: "PUT",
|
||||||
|
data: { entryIds: [entries[2].id, entries[0].id, entries[1].id] },
|
||||||
|
});
|
||||||
|
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
data: { durationOverrideMs: 30_000, backgroundModeOverride: "solid", backgroundColorOverride: "#204060" },
|
||||||
|
});
|
||||||
|
await api(request, "/api/settings", { method: "PATCH", data: { defaultBlurPx: 24 } });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await visibleScene(page))?.backgroundMode, { timeout: 30_000 })
|
||||||
|
.toBe("solid");
|
||||||
|
|
||||||
|
const after = await page.evaluate(() => ({
|
||||||
|
blackFrames: window.__blackFrames,
|
||||||
|
loadCount: window.__loadCount,
|
||||||
|
}));
|
||||||
|
expect(after.blackFrames, "presentation edits must not expose black").toBe(0);
|
||||||
|
expect(after.loadCount, "the page must not reload for a manifest change").toBe(1);
|
||||||
|
|
||||||
|
const diagnostics = await displayDiagnostics(page);
|
||||||
|
expect(diagnostics.renderedEntryId).toBe(before.renderedEntryId);
|
||||||
|
expect(diagnostics.renderedAssetId).toBe(before.renderedAssetId);
|
||||||
|
await page.evaluate(() => clearInterval(window.__watch));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a socket interruption keeps the last good scene and resynchronizes on reconnect", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const library = await ensureFixtures(request);
|
||||||
|
await activatePlaylist(request, "Reconnect", [
|
||||||
|
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
|
||||||
|
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/display");
|
||||||
|
await waitForFirstSlide(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__loadCount = (window.__loadCount || 0) + 1;
|
||||||
|
});
|
||||||
|
const before = await displayDiagnostics(page);
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.waitForTimeout(4_000);
|
||||||
|
|
||||||
|
// A disconnect must never black the display or reload the page.
|
||||||
|
const during = await page.evaluate(() => ({
|
||||||
|
opaque: [...document.querySelectorAll(".scene")].filter(
|
||||||
|
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
|
||||||
|
).length,
|
||||||
|
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
|
||||||
|
loadCount: window.__loadCount,
|
||||||
|
}));
|
||||||
|
expect(during.opaque).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(during.rendered).toBe(before.renderedAssetId);
|
||||||
|
expect(during.loadCount).toBe(1);
|
||||||
|
|
||||||
|
// The server moves on while the display cannot hear it.
|
||||||
|
await api(request, "/api/playback/next", { method: "POST" });
|
||||||
|
await context.setOffline(false);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
|
||||||
|
.toBe(library["wide-b"].id);
|
||||||
|
|
||||||
|
const playback = await api(request, "/api/playback");
|
||||||
|
const after = await displayDiagnostics(page);
|
||||||
|
expect(after.renderedEntryId).toBe(playback.currentEntryId);
|
||||||
|
expect(after.acceptedRevision).toBe(playback.playbackRevision);
|
||||||
|
expect(await page.evaluate(() => window.__loadCount)).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
function crc32(buffer) {
|
||||||
|
let table = [];
|
||||||
|
for (let n = 0; n < 256; n += 1) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of buffer) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(typed));
|
||||||
|
return Buffer.concat([length, typed, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostly flat images so the fixtures compress small; the tests identify slides
|
||||||
|
// by asset ID rather than by pixels.
|
||||||
|
export function makePng(width, height, [r, g, b]) {
|
||||||
|
const header = Buffer.alloc(13);
|
||||||
|
header.writeUInt32BE(width, 0);
|
||||||
|
header.writeUInt32BE(height, 4);
|
||||||
|
header[8] = 8;
|
||||||
|
header[9] = 2;
|
||||||
|
const raw = Buffer.alloc(height * (1 + width * 3));
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const offset = y * (1 + width * 3);
|
||||||
|
raw[offset] = 0;
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const p = offset + 1 + x * 3;
|
||||||
|
raw[p] = r;
|
||||||
|
raw[p + 1] = g;
|
||||||
|
raw[p + 2] = x < 64 && y < 64 ? 255 : b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
chunk("IHDR", header),
|
||||||
|
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIXTURES = [
|
||||||
|
{ name: "wide-a", width: 1920, height: 1080, rgb: [200, 30, 30] },
|
||||||
|
{ name: "wide-b", width: 1920, height: 1080, rgb: [30, 200, 30] },
|
||||||
|
{ name: "wide-c", width: 1280, height: 720, rgb: [30, 30, 200] },
|
||||||
|
{ name: "wide-d", width: 1920, height: 1080, rgb: [200, 200, 30] },
|
||||||
|
{ name: "portrait", width: 1080, height: 1920, rgb: [180, 60, 200] },
|
||||||
|
{ name: "square", width: 1200, height: 1200, rgb: [60, 180, 200] },
|
||||||
|
{ name: "standalone", width: 1920, height: 1080, rgb: [240, 140, 20] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||||
|
|
||||||
|
// Uploads are content-addressed, so re-running the suite reuses the same assets
|
||||||
|
// instead of piling up duplicates.
|
||||||
|
export async function ensureFixtures(request) {
|
||||||
|
const existing = await (await request.fetch(`${API}/api/assets`)).json();
|
||||||
|
const byName = new Map(existing.assets.map((asset) => [asset.displayName, asset]));
|
||||||
|
for (const fixture of FIXTURES) {
|
||||||
|
if (byName.has(fixture.name)) continue;
|
||||||
|
const response = await request.fetch(`${API}/api/assets`, {
|
||||||
|
method: "POST",
|
||||||
|
multipart: {
|
||||||
|
files: {
|
||||||
|
name: `${fixture.name}.png`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: makePng(fixture.width, fixture.height, fixture.rgb),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`fixture upload failed: ${response.status()} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const refreshed = await (await request.fetch(`${API}/api/assets`)).json();
|
||||||
|
return Object.fromEntries(refreshed.assets.map((asset) => [asset.displayName, asset]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||||
|
|
||||||
|
export async function api(request, path, { method = "GET", data } = {}) {
|
||||||
|
const response = await request.fetch(`${API}${path}`, {
|
||||||
|
method,
|
||||||
|
...(data === undefined
|
||||||
|
? {}
|
||||||
|
: { headers: { "content-type": "application/json" }, data }),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`${method} ${path} -> ${response.status()} ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assets(request) {
|
||||||
|
const list = await api(request, "/api/assets");
|
||||||
|
return Object.fromEntries(list.assets.map((asset) => [asset.displayName, asset]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every test gets its own playlist so fixtures never bleed between cases.
|
||||||
|
export async function activatePlaylist(request, name, entries) {
|
||||||
|
const playlist = await api(request, "/api/playlists", {
|
||||||
|
method: "POST",
|
||||||
|
data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 8)}` },
|
||||||
|
});
|
||||||
|
const created = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
created.push(
|
||||||
|
await api(request, `/api/playlists/${playlist.id}/entries`, {
|
||||||
|
method: "POST",
|
||||||
|
data: entry,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
// Activation legitimately queues behind a slide that is still mid-duration.
|
||||||
|
// A manual next is the documented way to take that boundary immediately, so
|
||||||
|
// fixtures do not have to wait out the previous test's slide.
|
||||||
|
if (activation.activation === "pending") {
|
||||||
|
await api(request, "/api/playback/next", { method: "POST" });
|
||||||
|
}
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await api(request, "/api/playback")).activePlaylistId, {
|
||||||
|
timeout: 90_000,
|
||||||
|
})
|
||||||
|
.toBe(playlist.id);
|
||||||
|
return { playlist, entries: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global presentation settings are shared state that individual tests
|
||||||
|
// legitimately change. Restoring the documented defaults before each test keeps
|
||||||
|
// every case deterministic no matter what ran before it.
|
||||||
|
export async function resetPlayback(request) {
|
||||||
|
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
|
||||||
|
const state = await api(request, "/api/playback");
|
||||||
|
if (state.transportState === "static" || state.transportState === "paused") {
|
||||||
|
await api(request, "/api/playback/resume", { method: "POST" });
|
||||||
|
}
|
||||||
|
await api(request, "/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
data: {
|
||||||
|
defaultBlurPx: 20,
|
||||||
|
defaultBackgroundMode: "blurred",
|
||||||
|
defaultSolidBackgroundColor: "#000000",
|
||||||
|
defaultBackgroundDarkened: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayDiagnostics(page) {
|
||||||
|
return page.evaluate(() => window.__display?.diagnostics() ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForFirstSlide(page) {
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId ?? null, {
|
||||||
|
timeout: 45_000,
|
||||||
|
})
|
||||||
|
.not.toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleScene(page) {
|
||||||
|
// The scene layer that is actually composited: opacity 1 and highest z-index.
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const layers = [...document.querySelectorAll(".scene")];
|
||||||
|
const painted = layers
|
||||||
|
.map((layer) => ({
|
||||||
|
layer: layer.dataset.layer,
|
||||||
|
sceneKey: layer.dataset.sceneKey || "",
|
||||||
|
fit: layer.dataset.fit || "",
|
||||||
|
backgroundMode: layer.dataset.backgroundMode || "",
|
||||||
|
darkened: layer.dataset.darkened || "",
|
||||||
|
shadow: layer.dataset.shadow || "",
|
||||||
|
opacity: Number(getComputedStyle(layer).opacity),
|
||||||
|
zIndex: Number(getComputedStyle(layer).zIndex || 0),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.opacity > 0.99);
|
||||||
|
return painted.sort((a, b) => b.zIndex - a.zIndex)[0] || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
-1
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'test-results', 'playwright-report']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -18,4 +18,14 @@ export default defineConfig([
|
|||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Browser tests and their tooling run in Node, not in the page.
|
||||||
|
files: ['e2e/**/*.js', 'playwright.config.js', 'src/display/__tests__/**/*.js'],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The signage app-shell worker runs in a Service Worker scope.
|
||||||
|
files: ['public/display-sw.js'],
|
||||||
|
languageOptions: { globals: { ...globals.serviceworker } },
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
+4
-1
@@ -10,7 +10,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:unit": "node --test \"src/display/__tests__/*.test.js\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// The display is verified against the real isolated stack through Nginx, which
|
||||||
|
// is how a kiosk actually reaches it. DISPLAY_BASE_URL is supplied by the
|
||||||
|
// verification harness.
|
||||||
|
const baseURL = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
// Signage timing is real wall-clock timing; these are not unit tests.
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 20_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
serviceWorkers: "allow",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/* Announcements signage app-shell worker.
|
||||||
|
*
|
||||||
|
* Scope is deliberately narrow: this worker makes the /display application
|
||||||
|
* shell available for a future offline start. Slide bytes are NOT its job —
|
||||||
|
* those are owned explicitly by the display asset manager in Cache Storage, so
|
||||||
|
* the two caches never compete for the same responsibility.
|
||||||
|
*/
|
||||||
|
const SHELL_CACHE = "announcements-display-shell-v1";
|
||||||
|
const SHELL_FALLBACK = "/display";
|
||||||
|
|
||||||
|
// Paths the worker must never handle. Media is the asset manager's; the API and
|
||||||
|
// socket are live server state and must never be answered from a cache.
|
||||||
|
const BYPASS_PREFIXES = ["/api/", "/socket.io/", "/media/", "/health"];
|
||||||
|
|
||||||
|
function isBypassed(url) {
|
||||||
|
return BYPASS_PREFIXES.some(
|
||||||
|
(prefix) => url.pathname === prefix.replace(/\/$/, "") || url.pathname.startsWith(prefix),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
await cache.add(new Request(SHELL_FALLBACK, { cache: "reload" }));
|
||||||
|
} catch {
|
||||||
|
// A first install while the network is unavailable still succeeds; the
|
||||||
|
// shell is captured on the next successful navigation instead.
|
||||||
|
}
|
||||||
|
// A kiosk must never be trapped on a stale application version.
|
||||||
|
await self.skipWaiting();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const name of await caches.keys()) {
|
||||||
|
// Only this worker's own shell generations are removed. The display
|
||||||
|
// asset cache is left strictly alone.
|
||||||
|
if (name.startsWith("announcements-display-shell-") && name !== SHELL_CACHE) {
|
||||||
|
await caches.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function networkFirst(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response && response.ok) {
|
||||||
|
// Keep the shell fallback fresh so an online kiosk always updates.
|
||||||
|
await cache.put(SHELL_FALLBACK, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const cached = (await cache.match(SHELL_FALLBACK)) || (await cache.match("/index.html"));
|
||||||
|
if (cached) return cached;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response && response.ok) await cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (isBypassed(url)) return;
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
// Online kiosks always get the newest application; offline ones get the
|
||||||
|
// last known good shell.
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vite emits content-hashed asset filenames, so these are safely immutable.
|
||||||
|
if (url.pathname.startsWith("/assets/")) {
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
}
|
||||||
|
});
|
||||||
+10
-71
@@ -1,74 +1,13 @@
|
|||||||
html,
|
.placeholder {
|
||||||
body,
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
#root {
|
margin: 0 auto;
|
||||||
background: red !important;
|
max-width: 40rem;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
color: #1f2933;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
.placeholder code {
|
||||||
margin: 0;
|
background: #eef1f5;
|
||||||
width: 100%;
|
border-radius: 0.25rem;
|
||||||
height: 100%;
|
padding: 0.1rem 0.35rem;
|
||||||
background: black;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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";
|
// Placeholder management route. The dashboard is a later phase; the signage
|
||||||
import { io } from "socket.io-client";
|
// output lives at /display.
|
||||||
import "./App.css";
|
|
||||||
|
|
||||||
const socket = io();
|
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<div className="slideshow">
|
<main className="placeholder">
|
||||||
{previousImage && (
|
<h1>Announcements</h1>
|
||||||
<img
|
<p>
|
||||||
key={`previous-${previousImage}`}
|
The signage output is served at <code>/display</code>.
|
||||||
className="slide previous"
|
</p>
|
||||||
src={previousImage}
|
</main>
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<img
|
|
||||||
key={`current-${currentImage}`}
|
|
||||||
className="slide current"
|
|
||||||
src={currentImage}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -7,12 +9,8 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: black;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: block;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
+21
-9
@@ -1,10 +1,22 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.jsx'
|
import App from "./App.jsx";
|
||||||
|
import DisplayApp from "./display/DisplayApp.jsx";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
|
||||||
<StrictMode>
|
|
||||||
<App />
|
// The service worker only backs the signage app shell. Slide bytes are owned by
|
||||||
</StrictMode>,
|
// 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>,
|
||||||
|
);
|
||||||
|
|||||||
Generated
+38
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1([email protected])
|
version: 10.0.1([email protected])
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: 1.62.1
|
||||||
|
version: 1.62.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.14
|
specifier: ^19.2.14
|
||||||
version: 19.2.18
|
version: 19.2.18
|
||||||
@@ -463,6 +466,11 @@ packages:
|
|||||||
'@oxc-project/[email protected]':
|
'@oxc-project/[email protected]':
|
||||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||||
|
|
||||||
|
'@playwright/[email protected]':
|
||||||
|
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
|
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -951,6 +959,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1294,6 +1307,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -2077,6 +2100,10 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/[email protected]': {}
|
'@oxc-project/[email protected]': {}
|
||||||
|
|
||||||
|
'@playwright/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.1
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2566,6 +2593,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2862,6 +2892,14 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.62.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.18
|
nanoid: 3.3.18
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ function boundedError(value) {
|
|||||||
: trimmed;
|
: trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The display serializes its structured error to bounded JSON, which the
|
||||||
|
// string validation above already accepts. Reading it back as an object keeps
|
||||||
|
// the status API useful without loosening input validation or needing a
|
||||||
|
// migration, since display_clients.last_error is TEXT.
|
||||||
|
function parseDisplayError(value) {
|
||||||
|
if (typeof value !== "string" || !value.startsWith("{")) return value ?? null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A plain string that merely looks like JSON stays a plain string.
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function validateHeartbeat(payload) {
|
function validateHeartbeat(payload) {
|
||||||
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
throw new TypeError("heartbeat must be an object");
|
throw new TypeError("heartbeat must be an object");
|
||||||
@@ -161,7 +178,7 @@ function createDisplayService({
|
|||||||
currentAssetId: row.current_asset_id,
|
currentAssetId: row.current_asset_id,
|
||||||
cachedAssetCount: row.cached_asset_count,
|
cachedAssetCount: row.cached_asset_count,
|
||||||
playableAssetCount: row.playable_asset_count,
|
playableAssetCount: row.playable_asset_count,
|
||||||
mostRecentError: row.last_error,
|
mostRecentError: parseDisplayError(row.last_error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,5 +340,6 @@ module.exports = {
|
|||||||
HEARTBEAT_PROTOCOL_VERSION,
|
HEARTBEAT_PROTOCOL_VERSION,
|
||||||
MAX_ERROR_LENGTH,
|
MAX_ERROR_LENGTH,
|
||||||
createDisplayService,
|
createDisplayService,
|
||||||
|
parseDisplayError,
|
||||||
validateHeartbeat,
|
validateHeartbeat,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ const test = require("node:test");
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { openDatabase } = require("../src/database");
|
const { openDatabase } = require("../src/database");
|
||||||
const { runMigrations } = require("../src/database/migrate");
|
const { runMigrations } = require("../src/database/migrate");
|
||||||
const { createDisplayService } = require("../src/displays/service");
|
const {
|
||||||
|
createDisplayService,
|
||||||
|
parseDisplayError,
|
||||||
|
} = require("../src/displays/service");
|
||||||
const { createNotificationService } = require("../src/notifications");
|
const { createNotificationService } = require("../src/notifications");
|
||||||
|
|
||||||
const BASE_TIME = 2_000_000_000_000;
|
const BASE_TIME = 2_000_000_000_000;
|
||||||
@@ -374,3 +377,48 @@ test("display status derives from heartbeat freshness for every known client", (
|
|||||||
const serialized = JSON.stringify(harness.service.list());
|
const serialized = JSON.stringify(harness.service.list());
|
||||||
assert.doesNotMatch(serialized, /socket|sid|transport/i);
|
assert.doesNotMatch(serialized, /socket|sid|transport/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a structured display error round-trips without loosening validation", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
// The real display serializes its structured error to bounded JSON, which the
|
||||||
|
// existing string validation already accepts.
|
||||||
|
const structured = JSON.stringify({
|
||||||
|
kind: "slide_decode_failed",
|
||||||
|
assetId: "asset-a",
|
||||||
|
entryId: "entry-a",
|
||||||
|
message: "Could not decode /media/assets/asset-a/display/abc.webp",
|
||||||
|
at: BASE_TIME,
|
||||||
|
});
|
||||||
|
const result = harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: structured }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.mostRecentError.kind, "slide_decode_failed");
|
||||||
|
assert.equal(result.mostRecentError.assetId, "asset-a");
|
||||||
|
assert.equal(result.mostRecentError.entryId, "entry-a");
|
||||||
|
assert.equal(
|
||||||
|
harness.rows()[0].last_error,
|
||||||
|
structured,
|
||||||
|
"the column still stores bounded text; no migration is required",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-JSON telemetry stays a plain string.
|
||||||
|
harness.clock.set(BASE_TIME + 5_000);
|
||||||
|
assert.equal(
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "plain failure" }))
|
||||||
|
.mostRecentError,
|
||||||
|
"plain failure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input validation is unchanged: objects are still rejected outright.
|
||||||
|
assert.throws(
|
||||||
|
() => harness.service.heartbeat(heartbeatPayload({ mostRecentError: { kind: "x" } })),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(parseDisplayError(null), null);
|
||||||
|
assert.equal(parseDisplayError("{not json"), "{not json");
|
||||||
|
assert.equal(parseDisplayError('{"no":"kind"}'), '{"no":"kind"}');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user