diff --git a/README.md b/README.md
index 035a003..5fdacf6 100644
--- a/README.md
+++ b/README.md
@@ -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
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
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
diff --git a/client/.gitignore b/client/.gitignore
index a547bf3..24f049d 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -22,3 +22,10 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+# Playwright browser test artifacts
+test-results/
+playwright-report/
+blob-report/
+.playwright/
+debug.mjs
diff --git a/client/e2e/display-basic.spec.js b/client/e2e/display-basic.spec.js
new file mode 100644
index 0000000..a87169d
--- /dev/null
+++ b/client/e2e/display-basic.spec.js
@@ -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$/);
+ }
+});
diff --git a/client/e2e/display-persistence.spec.js b/client/e2e/display-persistence.spec.js
new file mode 100644
index 0000000..9f9d0ea
--- /dev/null
+++ b/client/e2e/display-persistence.spec.js
@@ -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();
+});
diff --git a/client/e2e/display-transitions.spec.js b/client/e2e/display-transitions.spec.js
new file mode 100644
index 0000000..f790359
--- /dev/null
+++ b/client/e2e/display-transitions.spec.js
@@ -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);
+});
diff --git a/client/e2e/display-transport.spec.js b/client/e2e/display-transport.spec.js
new file mode 100644
index 0000000..9651a7b
--- /dev/null
+++ b/client/e2e/display-transport.spec.js
@@ -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);
+});
diff --git a/client/e2e/fixtures.js b/client/e2e/fixtures.js
new file mode 100644
index 0000000..de408cf
--- /dev/null
+++ b/client/e2e/fixtures.js
@@ -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]));
+}
diff --git a/client/e2e/helpers.js b/client/e2e/helpers.js
new file mode 100644
index 0000000..f82996c
--- /dev/null
+++ b/client/e2e/helpers.js
@@ -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;
+ });
+}
diff --git a/client/eslint.config.js b/client/eslint.config.js
index ea36dd3..c79dd94 100644
--- a/client/eslint.config.js
+++ b/client/eslint.config.js
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'test-results', 'playwright-report']),
{
files: ['**/*.{js,jsx}'],
extends: [
@@ -18,4 +18,14 @@ export default defineConfig([
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 } },
+ },
])
diff --git a/client/package.json b/client/package.json
index 538c7fb..c5622f6 100644
--- a/client/package.json
+++ b/client/package.json
@@ -10,7 +10,9 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test:e2e": "playwright test",
+ "test:unit": "node --test \"src/display/__tests__/*.test.js\""
},
"dependencies": {
"react": "^19.2.6",
@@ -19,6 +21,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "1.62.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
diff --git a/client/playwright.config.js b/client/playwright.config.js
new file mode 100644
index 0000000..653d541
--- /dev/null
+++ b/client/playwright.config.js
@@ -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"] } }],
+});
diff --git a/client/public/display-sw.js b/client/public/display-sw.js
new file mode 100644
index 0000000..477637a
--- /dev/null
+++ b/client/public/display-sw.js
@@ -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));
+ }
+});
diff --git a/client/src/App.css b/client/src/App.css
index fad2e32..f225a9a 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -1,74 +1,13 @@
-html,
-body,
-#root {
- background: red !important;
+.placeholder {
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+ margin: 0 auto;
+ max-width: 40rem;
+ padding: 3rem 1.5rem;
+ color: #1f2933;
}
-#root {
- margin: 0;
- width: 100%;
- height: 100%;
- background: black;
- overflow: hidden;
+.placeholder code {
+ background: #eef1f5;
+ border-radius: 0.25rem;
+ padding: 0.1rem 0.35rem;
}
-
-.slideshow {
- position: relative;
- width: 100vw;
- height: 100vh;
- background: black;
-}
-
-.slide {
- position: absolute;
- inset: 0;
- width: 100vw;
- height: 100vh;
- object-fit: cover !important;
-}
-
-.previous {
- opacity: 1;
- animation: fadeOut 1000ms ease-in-out forwards;
-}
-
-.current {
- opacity: 0;
- animation: fadeIn 1000ms ease-in-out forwards;
-}
-
-.empty {
- width: 100vw;
- height: 100vh;
- background: black;
- color: white;
- display: grid;
- place-items: center;
- font-family: sans-serif;
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-@keyframes fadeOut {
- from {
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-
-.blank-screen {
- position: fixed;
- inset: 0;
- background: black;
- width: 100vw;
- height: 100vh;
-}
\ No newline at end of file
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 843ce52..8970a50 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -1,67 +1,12 @@
-import { useEffect, useState } from "react";
-import { io } from "socket.io-client";
-import "./App.css";
-
-const socket = io();
-
+// Placeholder management route. The dashboard is a later phase; the signage
+// output lives at /display.
export default function App() {
- const [slides, setSlides] = useState([]);
- const [currentSlide, setCurrentSlide] = useState(0);
- const [previousSlide, setPreviousSlide] = useState(null);
- const [mode, setMode] = useState("on");
-
- useEffect(() => {
- socket.on("slideshow:state", (state) => {
- setSlides(state.slides || []);
- setMode(state.mode ?? "on");
-
- setCurrentSlide((oldSlide) => {
- if (oldSlide !== state.currentSlide) {
- setPreviousSlide(oldSlide);
- }
-
- return state.currentSlide;
- });
-
- setTimeout(() => {
- setPreviousSlide(null);
- }, 1100);
- });
-
- return () => {
- socket.off("slideshow:state");
- };
- }, []);
-
- if (mode === "off") {
- return
;
- }
-
- const currentImage = slides[currentSlide];
- const previousImage =
- previousSlide !== null ? slides[previousSlide] : null;
-
- if (!currentImage) {
- return No slides loaded
;
- }
-
return (
-
- {previousImage && (
-

- )}
-
-

-
+
+ Announcements
+
+ The signage output is served at /display.
+
+
);
}
diff --git a/client/src/assets/hero.png b/client/src/assets/hero.png
deleted file mode 100644
index 02251f4..0000000
Binary files a/client/src/assets/hero.png and /dev/null differ
diff --git a/client/src/assets/react.svg b/client/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/client/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/client/src/assets/vite.svg b/client/src/assets/vite.svg
deleted file mode 100644
index 5101b67..0000000
--- a/client/src/assets/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/client/src/display/DisplayApp.jsx b/client/src/display/DisplayApp.jsx
new file mode 100644
index 0000000..5ccab84
--- /dev/null
+++ b/client/src/display/DisplayApp.jsx
@@ -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 (
+
+
+
![]()
+
+
+
![]()
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/client/src/display/__tests__/display-logic.test.js b/client/src/display/__tests__/display-logic.test.js
new file mode 100644
index 0000000..d7a98e6
--- /dev/null
+++ b/client/src/display/__tests__/display-logic.test.js
@@ -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",
+ );
+});
diff --git a/client/src/display/assetCache.js b/client/src/display/assetCache.js
new file mode 100644
index 0000000..d579c10
--- /dev/null
+++ b/client/src/display/assetCache.js
@@ -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,
+ };
+}
diff --git a/client/src/display/display.css b/client/src/display/display.css
new file mode 100644
index 0000000..4ec5c89
--- /dev/null
+++ b/client/src/display/display.css
@@ -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;
+}
diff --git a/client/src/display/displayController.js b/client/src/display/displayController.js
new file mode 100644
index 0000000..38a674f
--- /dev/null
+++ b/client/src/display/displayController.js
@@ -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,
+ };
+ },
+ };
+}
diff --git a/client/src/display/displayDb.js b/client/src/display/displayDb.js
new file mode 100644
index 0000000..d426ed3
--- /dev/null
+++ b/client/src/display/displayDb.js
@@ -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 };
+}
diff --git a/client/src/display/errors.js b/client/src/display/errors.js
new file mode 100644
index 0000000..965af32
--- /dev/null
+++ b/client/src/display/errors.js
@@ -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;
+}
diff --git a/client/src/display/presentation.js b/client/src/display/presentation.js
new file mode 100644
index 0000000..9d5a4b6
--- /dev/null
+++ b/client/src/display/presentation.js
@@ -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,
+ };
+}
diff --git a/client/src/display/sceneLayer.js b/client/src/display/sceneLayer.js
new file mode 100644
index 0000000..7651ab0
--- /dev/null
+++ b/client/src/display/sceneLayer.js
@@ -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,
+ };
+}
diff --git a/client/src/display/snapshot.js b/client/src/display/snapshot.js
new file mode 100644
index 0000000..a12c2e9
--- /dev/null
+++ b/client/src/display/snapshot.js
@@ -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;
+}
diff --git a/client/src/index.css b/client/src/index.css
index 1c4b3ef..e43f635 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,3 +1,5 @@
+/* Neutral global baseline. Signage-specific fullscreen rules are scoped to the
+ display route in display.css so the future dashboard is not constrained. */
* {
box-sizing: border-box;
}
@@ -7,12 +9,8 @@ body,
#root {
margin: 0;
padding: 0;
- width: 100%;
- height: 100%;
- background: black;
- overflow: hidden;
}
body {
- display: block;
-}
\ No newline at end of file
+ min-height: 100%;
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index b9a1a6d..c3cb3d2 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -1,10 +1,22 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.jsx'
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.jsx";
+import DisplayApp from "./display/DisplayApp.jsx";
-createRoot(document.getElementById('root')).render(
-
-
- ,
-)
+const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
+
+// The service worker only backs the signage app shell. Slide bytes are owned by
+// the explicit Cache Storage asset manager, never by the worker.
+if (isDisplayRoute && "serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/display-sw.js", { scope: "/" }).catch(() => {
+ // A kiosk without a worker still runs; it just loses offline app-shell
+ // startup until the next successful registration.
+ });
+ });
+}
+
+createRoot(document.getElementById("root")).render(
+ {isDisplayRoute ? : },
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f3da87..3051183 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.8.1)
+ '@playwright/test':
+ specifier: 1.62.1
+ version: 1.62.1
'@types/react':
specifier: ^19.2.14
version: 19.2.18
@@ -463,6 +466,11 @@ packages:
'@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
+ '@playwright/test@1.62.1':
+ resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@rolldown/binding-android-arm64@1.2.4':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -951,6 +959,11 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1294,6 +1307,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -2077,6 +2100,10 @@ snapshots:
'@oxc-project/types@0.144.0': {}
+ '@playwright/test@1.62.1':
+ dependencies:
+ playwright: 1.62.1
+
'@rolldown/binding-android-arm64@1.2.4':
optional: true
@@ -2566,6 +2593,9 @@ snapshots:
fresh@2.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -2862,6 +2892,14 @@ snapshots:
picomatch@4.0.5: {}
+ playwright-core@1.62.1: {}
+
+ playwright@1.62.1:
+ dependencies:
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
diff --git a/server/src/displays/service.js b/server/src/displays/service.js
index 70310f7..2bac2f5 100644
--- a/server/src/displays/service.js
+++ b/server/src/displays/service.js
@@ -49,6 +49,23 @@ function boundedError(value) {
: 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) {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new TypeError("heartbeat must be an object");
@@ -161,7 +178,7 @@ function createDisplayService({
currentAssetId: row.current_asset_id,
cachedAssetCount: row.cached_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,
MAX_ERROR_LENGTH,
createDisplayService,
+ parseDisplayError,
validateHeartbeat,
};
diff --git a/server/test/displays.test.js b/server/test/displays.test.js
index 7c4c908..6c3931d 100644
--- a/server/test/displays.test.js
+++ b/server/test/displays.test.js
@@ -2,7 +2,10 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
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 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());
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"}');
+});