fix(display): keep blackout across reloads and follow the display's current assignment
A kiosk that reloaded during a blackout restored its last frame from local state without the blackout, so the hidden slide flashed on the wall until the server answered. A display resolved its channel once, at registration. Moving it to another group or audience, or deleting the channel it was on, never reached a running screen: it kept showing the old channel, or fell into offline continuation against a 404 forever. The display now fetches its state by viewer, so the server resolves the current assignment on every fetch, and the heartbeat reply names the assigned channel so a reassignment lands within one heartbeat. A display removed from the roster registers again once, on backoff; only Signage's own not-found means removal, so a foreign 404 can never cause a registration loop. The channel route stays for the preview and for display code a kiosk cached earlier. A replaced realtime subscription can no longer schedule a stray resubscription when it reports closing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6bf9167f85
commit
67d89f1020
@@ -200,7 +200,8 @@ describe("display ↔ server contract", () => {
|
||||
|
||||
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
||||
expect(sent).toContain("POST /viewers/session");
|
||||
expect(sent).toContain("GET /channels/:channelId/display-state");
|
||||
// By viewer, so the server — not the display's memory of its registration — decides the channel.
|
||||
expect(sent).toContain("GET /viewers/:viewerId/display-state");
|
||||
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
|
||||
// Not one refusal, of any kind, for anything the display built itself.
|
||||
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
|
||||
@@ -220,4 +221,48 @@ describe("display ↔ server contract", () => {
|
||||
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
|
||||
expect(viewer?.reportedContributionId).toBeNull();
|
||||
});
|
||||
|
||||
it("a display moved to another channel group is served that group's state on its next fetch", async () => {
|
||||
const display = controller();
|
||||
await display.start();
|
||||
await settle();
|
||||
const viewerId = display.diagnostics().viewerId;
|
||||
const firstChannel = display.diagnostics().channelId;
|
||||
expect(viewerId).toBeDefined();
|
||||
|
||||
// An administrator creates a second group and moves this screen to it while it is running.
|
||||
const groupId = newId();
|
||||
const channelId = newId();
|
||||
await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" });
|
||||
await store.updateViewer(viewerId as string, { channelId });
|
||||
|
||||
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||
const state = (await response.json()) as { channel: { id: string; groupId: string } };
|
||||
expect(state.channel).toMatchObject({ groupId, id: channelId });
|
||||
expect(channelId).not.toBe(firstChannel);
|
||||
|
||||
// And the heartbeat names the new assignment, which is how a display with a long slot hears.
|
||||
const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, {
|
||||
body: JSON.stringify({}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId);
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => {
|
||||
const display = controller();
|
||||
await display.start();
|
||||
await settle();
|
||||
const viewerId = display.diagnostics().viewerId as string;
|
||||
await store.updateViewer(viewerId, { channelId: null });
|
||||
|
||||
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||
expect(response.status).toBe(200);
|
||||
const [group] = await store.listChannelGroups();
|
||||
const fallback = group?.channels.find((channel) => channel.isDefault);
|
||||
expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id);
|
||||
await display.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user