fix(display): drop the previous assignment's frame when the new one has nothing
CI / typecheck (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 28s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
CI / typecheck (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 28s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
Moved from a group that was playing to one with nothing to show, a display adopted the new assignment but kept the old group's last image on screen indefinitely. With nothing renderable, the controller keeps whatever frame is up, which is right for a gap in the same assignment and wrong for a frame from an assignment the display no longer has. The same early return also skipped persisting, so an outage or a restart resumed the old group's content from local state. The controller now records which assignment the frame on screen came from. When the server says the current assignment has nothing, a frame from another assignment is taken down (a new stage clear(), which also abandons any transition still preparing) and the slate shows; and the new assignment's state is persisted if the stored one belongs to a different assignment. A gap within the same assignment still keeps its last good frame, and every path with content is unchanged. Regression tests: populated-group to empty-group reassignment drops the old image and shows the slate; and a restart with no server after that move does not resume the old group's content. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ab5b02960a
commit
51d9085cdb
@@ -80,6 +80,9 @@ export interface SceneStage {
|
|||||||
setSlate(on: boolean): void;
|
setSlate(on: boolean): void;
|
||||||
/** What is on screen now, or `undefined` if nothing ever was. */
|
/** What is on screen now, or `undefined` if nothing ever was. */
|
||||||
currentKey(): string | undefined;
|
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<void>;
|
||||||
setCrossfade(milliseconds: number): void;
|
setCrossfade(milliseconds: number): void;
|
||||||
destroy(): Promise<void>;
|
destroy(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
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,
|
currentKey: () => layers[currentIndex]?.key,
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let online = false;
|
let online = false;
|
||||||
let renderedSelectionId: string | undefined;
|
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 cachedAssetCount = 0;
|
||||||
let lastError: { code: string; detail: string | null } | undefined;
|
let lastError: { code: string; detail: string | null } | undefined;
|
||||||
let appliedRevision = -1;
|
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 {
|
function recordError(code: string, detail: string | null = null): void {
|
||||||
lastError = { code, detail };
|
lastError = { code, detail };
|
||||||
report();
|
report();
|
||||||
@@ -261,6 +268,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderedSelectionId = selection.id;
|
renderedSelectionId = selection.id;
|
||||||
|
frameAssignment = assignmentOf(state.channel);
|
||||||
if (lastError !== undefined) {
|
if (lastError !== undefined) {
|
||||||
lastError = undefined;
|
lastError = undefined;
|
||||||
}
|
}
|
||||||
@@ -287,11 +295,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
|
|
||||||
const target = state.staticHold ?? state.current;
|
const target = state.staticHold ?? state.current;
|
||||||
if (target === null) {
|
if (target === null) {
|
||||||
// Nothing renderable from the server. If something good is already on screen it stays; only a
|
const assignment = assignmentOf(state.channel);
|
||||||
// display with nothing at all shows the slate.
|
// 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) {
|
if (stage.currentKey() === undefined) {
|
||||||
stage.setSlate(true);
|
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();
|
report();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -428,6 +447,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
}
|
}
|
||||||
if (outcome.kind !== "superseded") {
|
if (outcome.kind !== "superseded") {
|
||||||
renderedSelectionId = selection.id;
|
renderedSelectionId = selection.id;
|
||||||
|
frameAssignment = assignmentOf(snapshot.channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,6 +546,128 @@ describe("display controller", () => {
|
|||||||
await controller.stop();
|
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<typeof createMemoryStore>) {
|
||||||
|
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 () => {
|
it("registers again, exactly once, when it was removed from the roster", async () => {
|
||||||
let sessions = 0;
|
let sessions = 0;
|
||||||
let subscriptions = 0;
|
let subscriptions = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user