diff --git a/src/browser/scene.ts b/src/browser/scene.ts index 7be6fad..ca776d2 100644 --- a/src/browser/scene.ts +++ b/src/browser/scene.ts @@ -80,6 +80,9 @@ export interface SceneStage { setSlate(on: boolean): void; /** What is on screen now, or `undefined` if nothing ever was. */ currentKey(): string | undefined; + /** Takes whatever is on screen down, and abandons any transition still preparing. For content + * that must no longer be shown at all — never for an ordinary change of slide. */ + clear(): Promise; setCrossfade(milliseconds: number): void; destroy(): Promise; } @@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage { } return { + async clear(): Promise { + if (destroyed) { + return; + } + // A transition that is still preparing belongs to the content being taken down. + generation += 1; + for (const layer of layers) { + layer.element.style.transitionDuration = "0ms"; + layer.element.style.opacity = "0"; + layer.element.dataset.visible = "false"; + } + await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]); + }, + currentKey: () => layers[currentIndex]?.key, async destroy(): Promise { diff --git a/src/display/controller.ts b/src/display/controller.ts index a091fb8..82b5654 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -118,6 +118,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp let stopped = false; let online = false; let renderedSelectionId: string | undefined; + /** The assignment the frame on screen came from. A frame is only ever kept for its own assignment. */ + let frameAssignment: string | undefined; let cachedAssetCount = 0; let lastError: { code: string; detail: string | null } | undefined; let appliedRevision = -1; @@ -169,6 +171,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp }; } + /** Identifies an assignment: the channel, within its group. */ + function assignmentOf(channel: { readonly id: string; readonly groupId: string }): string { + return `${channel.groupId}/${channel.id}`; + } + function recordError(code: string, detail: string | null = null): void { lastError = { code, detail }; report(); @@ -261,6 +268,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp return; } renderedSelectionId = selection.id; + frameAssignment = assignmentOf(state.channel); if (lastError !== undefined) { lastError = undefined; } @@ -287,11 +295,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp const target = state.staticHold ?? state.current; if (target === null) { - // Nothing renderable from the server. If something good is already on screen it stays; only a - // display with nothing at all shows the slate. + const assignment = assignmentOf(state.channel); + // Nothing renderable from the server. A good frame of *this* assignment stays through the gap; + // a frame from an assignment this display no longer has is stale content, and comes down. + if (frameAssignment !== undefined && frameAssignment !== assignment) { + stage.setSlate(true); + await stage.clear(); + renderedSelectionId = undefined; + frameAssignment = undefined; + } if (stage.currentKey() === undefined) { stage.setSlate(true); } + // What an outage or a restart resumes from must be this assignment too, never the old one. + if (snapshot !== undefined && assignmentOf(snapshot.channel) !== assignment) { + await persist(state); + } report(); return; } @@ -428,6 +447,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp } if (outcome.kind !== "superseded") { renderedSelectionId = selection.id; + frameAssignment = assignmentOf(snapshot.channel); } } diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts index 2ec7560..99f5bec 100644 --- a/tests/browser/display.test.ts +++ b/tests/browser/display.test.ts @@ -546,6 +546,128 @@ describe("display controller", () => { await controller.stop(); }); + /** + * Stage 0: moved from a group that was playing to one with nothing to show, the display adopted + * the new assignment and kept the old group's last image on screen indefinitely. "Keep the last + * good frame" protects a frame of the *same* assignment from a gap in it; a frame from an + * assignment this display no longer has is not a good frame, it is stale content. + */ + function movedToEmptyGroup(store: ReturnType) { + const moved = { groupId: "group-2", id: "chan-2", key: "online" }; + let stateFetches = 0; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + stateFetches += 1; + // Playing in group-1; moved to group-2, which has nothing, right after its first fetch. + return jsonResponse( + state( + stateFetches === 1 + ? { + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 1, + slots: [ + { + eligibleFromMs: null, + eligibleUntilMs: null, + enabled: true, + estimatedDurationMs: 5000, + id: "slot-1", + position: 0, + }, + ], + }, + } + : { + channel: moved, + current: null, + playback: { + activeTimelineId: null, + blackout: false, + currentSelectionId: null, + currentSlotId: null, + deadlineAtMs: null, + pendingTimelineId: null, + remainingMs: null, + revision: 1, + selectionRevision: null, + startedAtMs: null, + transport: "playing", + }, + selections: [], + }, + ), + ); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }), + }, + ], + }); + return { fetches: () => stateFetches, host, store }; + } + + it("stops showing the previous group's image when moved to a group with nothing to show", async () => { + const media = createMemoryMediaCache(); + const scenario = movedToEmptyGroup(createMemoryStore()); + const controller = build({ host: scenario.host, media, store: scenario.store }); + await controller.start(); + await settle(); + + expect(scenario.fetches()).toBe(2); + const diagnostics = controller.diagnostics(); + expect(diagnostics.channelId).toBe("chan-2"); + // The old group's image is gone — not merely covered — and the wall says so plainly. + expect(diagnostics.renderedSelectionId).toBeUndefined(); + expect(diagnostics.renderedKey).toBeUndefined(); + expect(diagnostics.slate).toBe(true); + expect(root.dataset.slate).toBe("true"); + expect(root.querySelector("img.signage-layer__foreground[src]")).toBeNull(); + await controller.stop(); + }); + + it("does not bring the previous group's content back from local state after that move", async () => { + const media = createMemoryMediaCache(); + const scenario = movedToEmptyGroup(createMemoryStore()); + const first = build({ host: scenario.host, media, store: scenario.store }); + await first.start(); + await settle(); + await first.stop(); + + // The kiosk restarts while the server is unreachable. It resumes from what it persisted — which + // must be its current assignment, not the group it was moved away from. + const unreachable = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + throw new Error("network is gone"); + }, + }, + ], + }); + const restarted = build({ host: unreachable, media, store: scenario.store }); + await restarted.start(); + await settle(); + + expect(restarted.diagnostics().renderedSelectionId).toBeUndefined(); + expect(root.dataset.slate).toBe("true"); + await restarted.stop(); + }); + it("registers again, exactly once, when it was removed from the roster", async () => { let sessions = 0; let subscriptions = 0;