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]>
103 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|