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 @@ -Vite 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"}'); +});