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

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:
ekklesia
2026-10-13 20:38:42 -05:00
co-authored by Claude Opus 5.5
parent ab5b02960a
commit 51d9085cdb
3 changed files with 161 additions and 2 deletions
+17
View File
@@ -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<void>;
setCrossfade(milliseconds: number): void;
destroy(): Promise<void>;
}
@@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage {
}
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,
async destroy(): Promise<void> {
+22 -2
View File
@@ -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);
}
}