Files
Signage/src/ui/panels/PreviewPanel.tsx
T
2026-08-28 16:41:54 -05:00

93 lines
3.1 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 { 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;
const controller = createDisplayController({ context, root, 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>
);
}