Files
Signage/src/ui/panels/PreviewPanel.tsx
T
ekklesiaandClaude Opus 5.5 7c17282616 fix(ui): keep Signage state consistent across tabs, and confirm destructive actions
Activating a timeline from Playback left Timelines showing it as inactive,
because each tab read its own stale copy of the channel groups; operators
read that as activation not sticking. Tabs also mounted every panel afresh, so
each switch flashed a spinner and collapsed the page before regrowing. Panels
now share one stale-while-revalidate resource cache, and Playback follows the
management realtime channel so Now showing tracks the wall.

Deleting an image, a timeline, a slot, a source, or an audience happened on a
single click; each is now a two-step in-place confirmation (DESIGN 21.3).

The audience key was presented as a field to fill in. Audiences are now named
in plain words, explained (images look the same for every audience), and the
content key is derived from the name with an Advanced override. The roster
labels signed-in browsers and lets an offline entry be removed after
confirmation, so test tabs and retired screens no longer accumulate. The
preview keeps nothing in the Dashboard origin's storage, and the narrow-screen
notice no longer names the platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:04 -05:00

103 lines
3.5 KiB
TypeScript

import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js";
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js";
/**
* The live preview.
*
* It mounts **the display controller itself** — the same scene stage, the same native-image
* presentation resolver, the same transition, the same renderer host — against the same channel
* state a wall reads. A preview that reimplemented any of that would drift from what the room
* actually sees, which makes it worse than no preview at all.
*
* It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no
* viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports
* health for a screen that does not exist. It drives its own refresh instead of subscribing.
*/
export function PreviewPanel({
api,
channelId,
context,
refreshMs = 2000,
}: {
readonly api: SignageApi;
readonly channelId: string | undefined;
readonly context: ModuleUiHostContext;
readonly refreshMs?: number;
}): ReactElement {
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root || channelId === undefined) {
return;
}
let stopped = false;
// The display's own rendering code, but nothing it keeps: a management browser is not a wall,
// so it persists no snapshot and caches no image bytes, and never restores one channel's last
// frame into another channel's preview.
const controller = createDisplayController({
context,
media: createMemoryMediaCache(),
root,
store: createMemoryStore(),
telemetry: false,
});
let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> {
if (stopped) {
return;
}
try {
const state = await api.get<DisplayChannelStateView>(`channels/${channelId}/display-state`);
if (!stopped) {
await controller.apply(state);
}
} catch {
// A preview that cannot reach the server simply keeps its last frame, exactly as a wall
// does. Nothing about a failed poll belongs in the operator's face.
}
if (!stopped) {
handle = setTimeout(() => void pump(), refreshMs);
}
}
void controller.start().then(() => pump());
return () => {
stopped = true;
if (handle !== undefined) {
clearTimeout(handle);
}
void controller.stop();
};
}, [api, channelId, context, refreshMs]);
return (
<Panel
description="Exactly what a display on this channel is showing, rendered by the display's own code."
title="Live preview"
>
{channelId === undefined ? (
<EmptyState title="No channel to preview" />
) : (
<div className="signage-preview">
<div className="signage-preview__frame">
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
<div className="signage-slate-host">
<SignageSlate />
</div>
<div aria-hidden="true" className="signage-blackout" />
</div>
</div>
</div>
)}
</Panel>
);
}