93 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|