feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user