feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
|
||||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import { DesktopOnly } from "../browser/desktop-gate.js";
|
||||
import { createSignageApi } from "./api.js";
|
||||
import { useResource } from "./hooks.js";
|
||||
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||
import { ProvidersPanel } from "./panels/ProvidersPanel.js";
|
||||
import { renderLoadable } from "./panels/shared.js";
|
||||
import { TimelinesPanel } from "./panels/TimelinesPanel.js";
|
||||
import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
||||
|
||||
/**
|
||||
* The Signage management screen.
|
||||
*
|
||||
* Host-neutral by construction: every value comes from the host context, and nothing here hardcodes
|
||||
* a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus
|
||||
* Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour.
|
||||
*
|
||||
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
||||
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||
*/
|
||||
export function SignageDashboard({
|
||||
context,
|
||||
}: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
}): ReactElement {
|
||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||
[mayView],
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedGroupId =
|
||||
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? (timelineId) => {
|
||||
void api
|
||||
.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||
timelineId,
|
||||
when: "now",
|
||||
})
|
||||
.then(() => setTab("playback"))
|
||||
.catch(() => setTab("playback"));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
{
|
||||
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* Signage's dashboard talking to Signage's own backend.
|
||||
*
|
||||
* Every path here is **relative to this module's own API root**, and the host resolves it. Nothing
|
||||
* in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment
|
||||
* detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else.
|
||||
*
|
||||
* Failures become one small, typed shape. The server already sanitizes what it says; this keeps a
|
||||
* network error and a refusal indistinguishable in the places where they should be — a screen that
|
||||
* cannot load — and distinguishable where they matter, in a form that can show *why* a save was
|
||||
* refused.
|
||||
*/
|
||||
|
||||
export interface SignageApiFailure {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
readonly detail?: Readonly<Record<string, number | string | null>> | undefined;
|
||||
}
|
||||
|
||||
export class SignageRequestError extends Error {
|
||||
readonly failure: SignageApiFailure;
|
||||
|
||||
constructor(failure: SignageApiFailure) {
|
||||
super(failure.message);
|
||||
this.name = "SignageRequestError";
|
||||
this.failure = failure;
|
||||
}
|
||||
}
|
||||
|
||||
const GENERIC: SignageApiFailure = {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
};
|
||||
|
||||
async function readFailure(response: Response): Promise<SignageApiFailure> {
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
if (typeof body.code === "string" && typeof body.message === "string") {
|
||||
return {
|
||||
code: body.code,
|
||||
...(typeof body.detail === "object" && body.detail !== null
|
||||
? { detail: body.detail as SignageApiFailure["detail"] }
|
||||
: {}),
|
||||
message: body.message,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A response that is not Signage's own error shape tells the operator nothing useful, and
|
||||
// guessing at it would put someone else's words on the screen.
|
||||
}
|
||||
return { ...GENERIC, status: response.status };
|
||||
}
|
||||
|
||||
export interface SignageApi {
|
||||
get<Result>(path: string): Promise<Result>;
|
||||
send<Result>(
|
||||
method: "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Result>;
|
||||
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
|
||||
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
|
||||
}
|
||||
|
||||
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
|
||||
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await client.request(path, init);
|
||||
} catch {
|
||||
throw new SignageRequestError(GENERIC);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SignageRequestError(await readFailure(response));
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as Result;
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as Result;
|
||||
} catch {
|
||||
throw new SignageRequestError({ ...GENERIC, status: response.status });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path) => request(path, undefined),
|
||||
send: (method, path, body) =>
|
||||
request(path, {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||
method,
|
||||
}),
|
||||
upload: (path, bytes, contentType) =>
|
||||
request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }),
|
||||
};
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
||||
|
||||
/**
|
||||
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
|
||||
*
|
||||
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
|
||||
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
|
||||
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
|
||||
* sentences.
|
||||
*/
|
||||
export type Loadable<Value> =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "ready"; readonly value: Value }
|
||||
| { readonly status: "failed"; readonly failure: SignageApiFailure };
|
||||
|
||||
export interface Resource<Value> {
|
||||
readonly state: Loadable<Value>;
|
||||
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
|
||||
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
|
||||
reload(): void;
|
||||
}
|
||||
|
||||
export function useResource<Value>(
|
||||
load: () => Promise<Value>,
|
||||
dependencies: readonly unknown[],
|
||||
): Resource<Value> {
|
||||
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
|
||||
const generation = useRef(0);
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
|
||||
const run = useCallback(() => {
|
||||
generation.current += 1;
|
||||
const token = generation.current;
|
||||
void loadRef
|
||||
.current()
|
||||
.then((value) => {
|
||||
// A response from a request the panel has already moved on from must never overwrite a
|
||||
// newer one.
|
||||
if (token === generation.current) {
|
||||
setState({ status: "ready", value });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (token !== generation.current) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
failure:
|
||||
error instanceof SignageRequestError
|
||||
? error.failure
|
||||
: {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
},
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
||||
// change, which is the whole reason this takes one.
|
||||
useEffect(run, [run, ...dependencies]);
|
||||
|
||||
return { reload: run, state };
|
||||
}
|
||||
|
||||
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
|
||||
* can say why a save was refused. */
|
||||
export interface Action {
|
||||
readonly pending: boolean;
|
||||
readonly failure: SignageApiFailure | undefined;
|
||||
run(work: () => Promise<unknown>): void;
|
||||
clearFailure(): void;
|
||||
}
|
||||
|
||||
export function useAction(onSettled?: () => void): Action {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
|
||||
const settled = useRef(onSettled);
|
||||
settled.current = onSettled;
|
||||
|
||||
const run = useCallback((work: () => Promise<unknown>) => {
|
||||
setPending(true);
|
||||
setFailure(undefined);
|
||||
void work()
|
||||
.then(() => {
|
||||
setPending(false);
|
||||
settled.current?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setPending(false);
|
||||
setFailure(
|
||||
error instanceof SignageRequestError
|
||||
? error.failure
|
||||
: {
|
||||
code: "SIGNAGE_REQUEST_FAILED",
|
||||
message: "Signage could not be reached.",
|
||||
status: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { clearFailure: () => setFailure(undefined), failure, pending, run };
|
||||
}
|
||||
|
||||
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
|
||||
* inventing its own timer. Paused when the interval is zero. */
|
||||
export function useTick(intervalMs: number): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
|
||||
return () => clearInterval(handle);
|
||||
}, [intervalMs]);
|
||||
return tick;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SignageDashboard } from "./SignageDashboard.js";
|
||||
import ownStyles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it
|
||||
* imports into the single browser artifact `entrypoints.ui` declares.
|
||||
*
|
||||
* `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own —
|
||||
* compiled in as text and installed by the host into this artifact's isolated root at mount
|
||||
* (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root
|
||||
* rather than bundled (ADR 0039).
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.0",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<SignageDashboard context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
Field,
|
||||
Panel,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Channel groups, audience channels, and the display roster.
|
||||
*
|
||||
* A church starts with one group and one default channel. `in-person` and `online` are created here,
|
||||
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
|
||||
* understand and ignore (ADR 0004).
|
||||
*/
|
||||
export function DisplaysPanel({
|
||||
api,
|
||||
canManage,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const viewers = useResource(
|
||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||
[],
|
||||
);
|
||||
const edit = useAction(() => {
|
||||
groups.reload();
|
||||
viewers.reload();
|
||||
});
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
||||
groupId: "",
|
||||
key: "",
|
||||
name: "",
|
||||
});
|
||||
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
|
||||
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
|
||||
useTick(5000);
|
||||
const nowMs = Date.now();
|
||||
|
||||
const channels =
|
||||
groups.state.status === "ready"
|
||||
? groups.state.value.groups.flatMap((group) =>
|
||||
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form">
|
||||
<Field label="New channel group">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
placeholder="Nursery hallway"
|
||||
value={groupName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={edit.pending || groupName.trim().length === 0}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", "channel-groups", { name: groupName.trim() });
|
||||
setGroupName("");
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="A group is one clock. An audience channel is one rendering of it."
|
||||
title="Channel groups"
|
||||
>
|
||||
{renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<div className="signage-groups" data-testid="signage-groups">
|
||||
{value.groups.map((group) => (
|
||||
<div className="signage-group" key={group.id}>
|
||||
<div className="signage-group__header">
|
||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
</div>
|
||||
<ul className="ek-list">
|
||||
{group.channels.map((channel) => (
|
||||
<li className="ek-list__item signage-row" key={channel.id}>
|
||||
<span className="signage-row__name">{channel.name}</span>
|
||||
<Badge tone={channel.isDefault ? "success" : "neutral"}>
|
||||
{channel.key}
|
||||
</Badge>
|
||||
{canManage && !channel.isDefault ? (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : undefined}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canManage ? (
|
||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
||||
<Field hint="Lowercase, e.g. online" label="Audience key">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
groupId: group.id,
|
||||
key: event.target.value,
|
||||
name: channelDraft.name,
|
||||
})
|
||||
}
|
||||
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Channel name">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
groupId: group.id,
|
||||
key: channelDraft.key,
|
||||
name: event.target.value,
|
||||
})
|
||||
}
|
||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={
|
||||
edit.pending ||
|
||||
channelDraft.groupId !== group.id ||
|
||||
channelDraft.key.trim() === "" ||
|
||||
channelDraft.name.trim() === ""
|
||||
}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
||||
key: channelDraft.key.trim(),
|
||||
name: channelDraft.name.trim(),
|
||||
});
|
||||
setChannelDraft({ groupId: "", key: "", name: "" });
|
||||
})
|
||||
}
|
||||
>
|
||||
Add audience channel
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading channel groups…", onRetry: groups.reload },
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
description="Every screen Signage has seen, and what it last reported."
|
||||
title="Displays"
|
||||
>
|
||||
{renderLoadable(
|
||||
viewers.state,
|
||||
(value) =>
|
||||
value.viewers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Open a Signage display on a screen and it will register itself here."
|
||||
title="No displays yet"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Display health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Name</TableCell>
|
||||
<TableCell header>State</TableCell>
|
||||
<TableCell header>Channel</TableCell>
|
||||
<TableCell header>Last seen</TableCell>
|
||||
<TableCell header>Offline ready</TableCell>
|
||||
<TableCell header>Most recent error</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.viewers.map((viewer) => (
|
||||
<TableRow key={viewer.id}>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<TextInput
|
||||
aria-label={`Name for ${viewer.name}`}
|
||||
defaultValue={viewer.name}
|
||||
onBlur={(event) =>
|
||||
event.target.value !== viewer.name &&
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
name: event.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
viewer.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OnlineBadge online={viewer.online} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<Select
|
||||
aria-label={`Channel for ${viewer.name}`}
|
||||
onChange={(event) =>
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
channelId: event.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
value={viewer.channelId ?? ""}
|
||||
>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{`${channel.groupName} · ${channel.name}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{viewer.lastError === null ? (
|
||||
"—"
|
||||
) : (
|
||||
<span title={viewer.lastError.detail ?? undefined}>
|
||||
{viewer.lastError.code}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
||||
)}
|
||||
<FailureNote failure={edit.failure} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
|
||||
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createUploadQueue,
|
||||
type UploadFile,
|
||||
type UploadItem,
|
||||
type UploadQueue,
|
||||
} from "../../browser/uploads.js";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource } from "../hooks.js";
|
||||
import type { ImageRecord, SettingsResponse } from "../types.js";
|
||||
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* The native image library and its upload queue.
|
||||
*
|
||||
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
|
||||
* individually bounded requests, a few at a time. The page stays responsive because only a few are
|
||||
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
|
||||
* the wrong type before a byte is sent, while the server still checks both again.
|
||||
*/
|
||||
export function ImagesPanel({
|
||||
api,
|
||||
settings,
|
||||
canManage,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canManage: boolean;
|
||||
}): ReactElement {
|
||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
||||
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
||||
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
||||
const rename = useAction(() => {
|
||||
setRenaming(undefined);
|
||||
images.reload();
|
||||
});
|
||||
const remove = useAction(() => images.reload());
|
||||
|
||||
const queue = useMemo(() => {
|
||||
const created = createUploadQueue({
|
||||
concurrency: settings.uploads.concurrency,
|
||||
maxBytes: settings.uploads.maxBytes,
|
||||
maxFiles: settings.uploads.batchMaxFiles,
|
||||
onChange: (items) => setUploads(items),
|
||||
upload: async (file) => {
|
||||
// One image per request, with the label in the query string so the body is exactly the
|
||||
// bytes. The server streams and counts them; nothing here buffers a second copy.
|
||||
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
|
||||
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
|
||||
},
|
||||
});
|
||||
queueRef.current = created;
|
||||
return created;
|
||||
}, [
|
||||
api,
|
||||
settings.uploads.batchMaxFiles,
|
||||
settings.uploads.concurrency,
|
||||
settings.uploads.maxBytes,
|
||||
]);
|
||||
|
||||
const onFiles = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
queue.enqueue(files);
|
||||
void queue.drain().then(() => images.reload());
|
||||
event.target.value = "";
|
||||
},
|
||||
[images, queue],
|
||||
);
|
||||
|
||||
const finished = uploads.filter(
|
||||
(item) => item.status !== "queued" && item.status !== "uploading",
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<label className="signage-upload-trigger">
|
||||
<span className="ek-button ek-button--primary">Add images</span>
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="signage-upload-input"
|
||||
data-testid="signage-upload-input"
|
||||
multiple
|
||||
onChange={onFiles}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
|
||||
title="Images"
|
||||
>
|
||||
{uploads.length === 0 ? undefined : (
|
||||
<div className="signage-uploads" data-testid="signage-uploads">
|
||||
<ul className="ek-list">
|
||||
{uploads.map((item) => (
|
||||
<li className="ek-list__item signage-row" key={item.id}>
|
||||
<span className="signage-row__name">{item.name}</span>
|
||||
<Badge
|
||||
tone={
|
||||
item.status === "done"
|
||||
? "success"
|
||||
: item.status === "failed" || item.status === "rejected"
|
||||
? "danger"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
{item.message === undefined ? undefined : (
|
||||
<span className="signage-uploads__message">{item.message}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{finished.length === 0 ? undefined : (
|
||||
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderLoadable(
|
||||
images.state,
|
||||
(value) =>
|
||||
value.images.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
|
||||
title="No images yet"
|
||||
/>
|
||||
) : (
|
||||
<ul className="signage-image-grid" data-testid="signage-image-grid">
|
||||
{value.images.map((image) => {
|
||||
const thumbnail = image.renditions.find(
|
||||
(rendition) => rendition.kind === "thumbnail",
|
||||
);
|
||||
return (
|
||||
<li className="signage-image" key={image.id}>
|
||||
{thumbnail === undefined ? (
|
||||
<div className="signage-image__missing">No preview</div>
|
||||
) : (
|
||||
<img
|
||||
alt={image.displayName}
|
||||
className="signage-image__thumbnail"
|
||||
height={thumbnail.height}
|
||||
src={thumbnail.url}
|
||||
width={thumbnail.width}
|
||||
/>
|
||||
)}
|
||||
<div className="signage-image__meta">
|
||||
{renaming?.id === image.id ? (
|
||||
<Field label="Name">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setRenaming({ id: image.id, value: event.target.value })
|
||||
}
|
||||
value={renaming.value}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<p className="signage-image__name">{image.displayName}</p>
|
||||
)}
|
||||
<p className="signage-image__detail">
|
||||
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
|
||||
</p>
|
||||
{image.expiresAt === null ? undefined : (
|
||||
<Badge className="signage-image__expiry" tone="warning">
|
||||
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="signage-image__actions">
|
||||
{renaming?.id === image.id ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={rename.pending}
|
||||
onClick={() =>
|
||||
rename.run(() =>
|
||||
api.send("PATCH", `images/${image.id}`, {
|
||||
displayName: renaming.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={remove.pending}
|
||||
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
|
||||
variant="danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
{ label: "Loading images…", onRetry: images.reload },
|
||||
)}
|
||||
<FailureNote failure={rename.failure ?? remove.failure} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type {
|
||||
ChannelGroupView,
|
||||
PlaybackDetailView,
|
||||
SettingsResponse,
|
||||
TimelineSummary,
|
||||
} from "../types.js";
|
||||
import { PreviewPanel } from "./PreviewPanel.js";
|
||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Live transport for one channel group.
|
||||
*
|
||||
* Every control here is the same operation the `signage.playback` capability exposes, over the same
|
||||
* serialized service — a volunteer with a phone and an operator at this screen are pressing the same
|
||||
* button, and neither can produce a state the other did not ask for.
|
||||
*/
|
||||
export function PlaybackPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
canControl,
|
||||
groupId,
|
||||
onGroupChange,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canControl: boolean;
|
||||
readonly groupId: string | undefined;
|
||||
readonly onGroupChange: (groupId: string) => void;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const detail = useResource(
|
||||
() =>
|
||||
groupId === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||
[groupId],
|
||||
);
|
||||
const command = useAction(() => {
|
||||
detail.reload();
|
||||
groups.reload();
|
||||
});
|
||||
const [activateId, setActivateId] = useState("");
|
||||
const previewChannelId =
|
||||
groups.state.status === "ready"
|
||||
? (groups.state.value.groups
|
||||
.find((group) => group.id === groupId)
|
||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||
: undefined;
|
||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||
useTick(1000);
|
||||
|
||||
function run(path: string, body?: unknown): void {
|
||||
if (groupId === undefined) {
|
||||
return;
|
||||
}
|
||||
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<Field label="Channel group">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => onGroupChange(event.target.value)}
|
||||
value={groupId ?? ""}
|
||||
>
|
||||
{value.groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
),
|
||||
{ label: "Loading groups…" },
|
||||
)}
|
||||
description="One clock for every channel in the group."
|
||||
title="Playback"
|
||||
>
|
||||
{groupId === undefined ? (
|
||||
<EmptyState title="No channel group selected" />
|
||||
) : (
|
||||
renderLoadable(
|
||||
detail.state,
|
||||
(value) =>
|
||||
value === undefined ? (
|
||||
<EmptyState title="No channel group selected" />
|
||||
) : (
|
||||
<div className="signage-playback" data-testid="signage-playback">
|
||||
<div className="signage-playback__status">
|
||||
<Badge
|
||||
tone={
|
||||
value.snapshot.transport === "playing"
|
||||
? "success"
|
||||
: value.snapshot.transport === "idle"
|
||||
? "warning"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{value.snapshot.transport}
|
||||
</Badge>
|
||||
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
||||
{value.current?.stale === true ? (
|
||||
<Badge tone="warning">Stale</Badge>
|
||||
) : undefined}
|
||||
{value.current?.withdrawn === true ? (
|
||||
<Badge tone="warning">Withdrawn</Badge>
|
||||
) : undefined}
|
||||
{value.snapshot.pendingTimelineId === null ? undefined : (
|
||||
<Badge tone="info">Activation queued</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.current === null ? (
|
||||
<EmptyState
|
||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||
title="Nothing on screen"
|
||||
/>
|
||||
) : (
|
||||
<dl className="signage-playback__current" data-testid="signage-current">
|
||||
<dt>Provider</dt>
|
||||
<dd>{value.current.providerId}</dd>
|
||||
<dt>Item</dt>
|
||||
<dd>{value.current.itemId}</dd>
|
||||
<dt>Renderer</dt>
|
||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||
<dt>Remaining</dt>
|
||||
<dd data-testid="signage-remaining">
|
||||
{value.snapshot.remainingMs === null
|
||||
? "—"
|
||||
: formatDuration(value.snapshot.remainingMs)}
|
||||
</dd>
|
||||
<dt>Started</dt>
|
||||
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__controls" data-testid="signage-controls">
|
||||
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||
Previous
|
||||
</Button>
|
||||
{value.snapshot.transport === "paused" ? (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("resume")}
|
||||
variant="primary"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||
>
|
||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||
</Button>
|
||||
{value.snapshot.staticAssetId === null ? undefined : (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("static", { assetId: null })}
|
||||
>
|
||||
Release hold
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={command.pending} onClick={() => run("reload")}>
|
||||
Reload content
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__activate">
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(loaded) => (
|
||||
<>
|
||||
<Field label="Activate timeline">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => setActivateId(event.target.value)}
|
||||
value={activateId}
|
||||
>
|
||||
<option value="">Choose a timeline…</option>
|
||||
{loaded.timelines.map((timeline) => (
|
||||
<option key={timeline.id} value={timeline.id}>
|
||||
{timeline.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={command.pending || activateId === ""}
|
||||
onClick={() =>
|
||||
run("activate", { timelineId: activateId, when: "now" })
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Now
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending || activateId === ""}
|
||||
onClick={() =>
|
||||
run("activate", { timelineId: activateId, when: "next-boundary" })
|
||||
}
|
||||
>
|
||||
At next slot
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
{ label: "Loading timelines…" },
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{value.history.length === 0 ? undefined : (
|
||||
<div className="signage-playback__history">
|
||||
<h4 className="signage-editor__subtitle">Recently played</h4>
|
||||
<ul className="ek-list">
|
||||
{value.history.slice(0, 8).map((entry) => (
|
||||
<li className="ek-list__item signage-row" key={entry.id}>
|
||||
<span className="signage-row__name">{entry.itemId}</span>
|
||||
<span className="signage-playback__history-meta">
|
||||
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading playback…", onRetry: detail.reload },
|
||||
)
|
||||
)}
|
||||
<FailureNote failure={command.failure} />
|
||||
</Panel>
|
||||
|
||||
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
Badge,
|
||||
EmptyState,
|
||||
Panel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@ekklesia/ui";
|
||||
import type { ReactElement } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useResource } from "../hooks.js";
|
||||
import type { ProviderHealth, SettingsResponse } from "../types.js";
|
||||
import { formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
|
||||
|
||||
/**
|
||||
* Provider health, and the one thing only a browser can tell an operator.
|
||||
*
|
||||
* A registration is a **running fact**: a provider that was uninstalled or disabled simply is not
|
||||
* here, and there is no stored row claiming otherwise. Everything shown is either the provider's own
|
||||
* declared identity or a count Signage kept — never a provider's exception, message, or payload.
|
||||
*
|
||||
* Renderer availability is checked here rather than on the server because the server genuinely
|
||||
* cannot know it: whether the platform published a module's contribution is a fact the *host* holds,
|
||||
* and `context.contributions` is where it is legible. A provider whose renderer was not published —
|
||||
* because the module is missing, disabled, or outside Signage's compatible range — is reported as
|
||||
* exactly that, which is the difference between "this is broken" and "this needs an update".
|
||||
*/
|
||||
export function ProvidersPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
}): ReactElement {
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const published = new Set(
|
||||
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
|
||||
title="Content providers"
|
||||
>
|
||||
{renderLoadable(
|
||||
providers.state,
|
||||
(value) =>
|
||||
value.providers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Signage works on its own with native images. Install a content module to add more."
|
||||
title="No content providers registered"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Provider health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Provider</TableCell>
|
||||
<TableCell header>Contract</TableCell>
|
||||
<TableCell header>Renderer</TableCell>
|
||||
<TableCell header>Health</TableCell>
|
||||
<TableCell header>Last success</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.providers.map((provider) => {
|
||||
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||
return (
|
||||
<TableRow key={provider.providerId}>
|
||||
<TableCell>
|
||||
<span className="signage-provider__id">{provider.providerId}</span>
|
||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||
</TableCell>
|
||||
<TableCell>{provider.contractVersion}</TableCell>
|
||||
<TableCell>
|
||||
{isNative ? (
|
||||
<Badge tone="success">Signage renders this</Badge>
|
||||
) : rendererPublished ? (
|
||||
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||
) : (
|
||||
<Badge tone="warning">Renderer not published</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.consecutiveFailures === 0 ? (
|
||||
<Badge tone="success">Healthy</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">
|
||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading providers…", onRetry: providers.reload },
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Field,
|
||||
Panel,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource } from "../hooks.js";
|
||||
import type {
|
||||
ImageRecord,
|
||||
ProviderHealth,
|
||||
ProviderItemDescriptor,
|
||||
ProviderSourceDescriptor,
|
||||
SettingsResponse,
|
||||
TimelineDetail,
|
||||
TimelineSummary,
|
||||
} from "../types.js";
|
||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||
|
||||
const NATIVE_PROVIDER_ID = "signage.native-images";
|
||||
|
||||
/**
|
||||
* The timeline editor.
|
||||
*
|
||||
* The pattern an operator arranges here is the product: an ordered list of slots, each either a
|
||||
* provider source that asks for the next item or one pinned item. Repeating a source is how a loop
|
||||
* gets three announcements in a row — the five occurrences share one rotation, and this screen makes
|
||||
* that visible by naming the source on every row rather than hiding it.
|
||||
*/
|
||||
export function TimelinesPanel({
|
||||
api,
|
||||
settings,
|
||||
canManage,
|
||||
onActivate,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canManage: boolean;
|
||||
readonly onActivate: ((timelineId: string) => void) | undefined;
|
||||
}): ReactElement {
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||
const [newName, setNewName] = useState("");
|
||||
const create = useAction(() => {
|
||||
setNewName("");
|
||||
timelines.reload();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form">
|
||||
<Field label="New timeline">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="Sunday morning"
|
||||
value={newName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={create.pending || newName.trim().length === 0}
|
||||
onClick={() =>
|
||||
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="Saved patterns an operator builds on demand. Signage ships none."
|
||||
title="Timelines"
|
||||
>
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(value) =>
|
||||
value.timelines.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Create one and add slots to it. There are no presets, on purpose."
|
||||
title="No timelines yet"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Saved timelines">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Name</TableCell>
|
||||
<TableCell header>Eligible</TableCell>
|
||||
<TableCell header>Revision</TableCell>
|
||||
<TableCell header>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.timelines.map((timeline) => (
|
||||
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
|
||||
<TableCell>
|
||||
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
|
||||
? "Always"
|
||||
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
||||
</TableCell>
|
||||
<TableCell>{timeline.revision}</TableCell>
|
||||
<TableCell>
|
||||
{onActivate === undefined ? undefined : (
|
||||
<Button onClick={() => onActivate(timeline.id)} variant="primary">
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading timelines…", onRetry: timelines.reload },
|
||||
)}
|
||||
<FailureNote failure={create.failure} />
|
||||
</Panel>
|
||||
|
||||
{selectedId === undefined ? undefined : (
|
||||
<TimelineEditor
|
||||
api={api}
|
||||
canManage={canManage}
|
||||
key={selectedId}
|
||||
onChanged={timelines.reload}
|
||||
onClose={() => setSelectedId(undefined)}
|
||||
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
|
||||
settings={settings}
|
||||
timelineId={selectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEditor({
|
||||
api,
|
||||
canManage,
|
||||
onChanged,
|
||||
onClose,
|
||||
providers,
|
||||
settings,
|
||||
timelineId,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
readonly onChanged: () => void;
|
||||
readonly onClose: () => void;
|
||||
readonly providers: readonly ProviderHealth[];
|
||||
readonly settings: SettingsResponse;
|
||||
readonly timelineId: string;
|
||||
}): ReactElement {
|
||||
const detail = useResource(
|
||||
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
||||
[timelineId],
|
||||
);
|
||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||
const edit = useAction(() => {
|
||||
detail.reload();
|
||||
onChanged();
|
||||
});
|
||||
const [providerId, setProviderId] = useState("");
|
||||
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
|
||||
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
||||
|
||||
function loadProvider(nextProviderId: string): void {
|
||||
setProviderId(nextProviderId);
|
||||
setSources([]);
|
||||
setItems([]);
|
||||
if (nextProviderId === "") {
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
|
||||
`providers/${encodeURIComponent(nextProviderId)}/sources`,
|
||||
)
|
||||
.then((response) => setSources(response.sources))
|
||||
.catch(() => setSources([]));
|
||||
void api
|
||||
.get<{ items: readonly ProviderItemDescriptor[] }>(
|
||||
`providers/${encodeURIComponent(nextProviderId)}/items`,
|
||||
)
|
||||
.then((response) => setItems(response.items))
|
||||
.catch(() => setItems([]));
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
actions={<Button onClick={onClose}>Close</Button>}
|
||||
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
|
||||
title="Timeline pattern"
|
||||
>
|
||||
{renderLoadable(
|
||||
detail.state,
|
||||
(value) => (
|
||||
<div className="signage-editor" data-testid="signage-timeline-editor">
|
||||
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
||||
|
||||
{canManage ? (
|
||||
<div className="signage-editor__add" data-testid="signage-add-source">
|
||||
<Field
|
||||
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
||||
label="Content provider"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => loadProvider(event.target.value)}
|
||||
value={providerId}
|
||||
>
|
||||
<option value="">Choose a provider…</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.providerId} value={provider.providerId}>
|
||||
{provider.providerId}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
{sources.map((source) => (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
key={source.id}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/sources`, {
|
||||
label: source.label,
|
||||
providerId,
|
||||
sourceId: source.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`Add source: ${source.label}`}
|
||||
</Button>
|
||||
))}
|
||||
{items.slice(0, 20).map((item) => (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||
itemId: item.id,
|
||||
providerId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`Pin item: ${item.label}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{value.sources.length === 0 ? undefined : (
|
||||
<div className="signage-editor__sources">
|
||||
<h4 className="signage-editor__subtitle">Configured sources</h4>
|
||||
{value.sources.map((source) => (
|
||||
<div className="signage-editor__source" key={source.id}>
|
||||
<span>{source.label}</span>
|
||||
<Badge tone="info">{source.providerId}</Badge>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||
sourceId: source.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Add slot
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.slots.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
|
||||
title="This timeline has no slots"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Timeline slots">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>#</TableCell>
|
||||
<TableCell header>Content</TableCell>
|
||||
<TableCell header>Duration</TableCell>
|
||||
<TableCell header>Enabled</TableCell>
|
||||
<TableCell header>Order</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.slots.map((slot, index) => {
|
||||
const source = value.sources.find(
|
||||
(candidate) => candidate.id === slot.sourceId,
|
||||
);
|
||||
const image =
|
||||
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
|
||||
? images.state.value.images.find(
|
||||
(candidate) => candidate.id === slot.itemId,
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow key={slot.id}>
|
||||
<TableCell>{slot.position + 1}</TableCell>
|
||||
<TableCell>
|
||||
{slot.kind === "provider-source"
|
||||
? (source?.label ?? "Removed source")
|
||||
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{slot.durationOverrideMs === null
|
||||
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
|
||||
: formatDuration(slot.durationOverrideMs)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={slot.enabled}
|
||||
disabled={!canManage || edit.pending}
|
||||
label={slot.enabled ? "On" : "Off"}
|
||||
onChange={(event) =>
|
||||
edit.run(() =>
|
||||
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
|
||||
enabled: event.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<div className="signage-editor__order">
|
||||
<Button
|
||||
disabled={index === 0 || edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||
slotIds: swap(
|
||||
value.slots.map((candidate) => candidate.id),
|
||||
index,
|
||||
index - 1,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
disabled={index === value.slots.length - 1 || edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||
slotIds: swap(
|
||||
value.slots.map((candidate) => candidate.id),
|
||||
index,
|
||||
index + 1,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Down
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="signage-editor__footer">
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(() =>
|
||||
api.send("POST", `timelines/${timelineId}/duplicate`, {
|
||||
name: `${value.timeline.name} (copy)`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("DELETE", `timelines/${timelineId}`);
|
||||
onClose();
|
||||
})
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Delete timeline
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading timeline…", onRetry: detail.reload },
|
||||
)}
|
||||
<FailureNote failure={edit.failure} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function swap(ids: readonly string[], from: number, to: number): string[] {
|
||||
const next = [...ids];
|
||||
const moved = next[from];
|
||||
const target = next[to];
|
||||
if (moved === undefined || target === undefined) {
|
||||
return next;
|
||||
}
|
||||
next[from] = target;
|
||||
next[to] = moved;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { SignageApiFailure } from "../api.js";
|
||||
import type { Loadable } from "../hooks.js";
|
||||
|
||||
/**
|
||||
* The one place a panel's loading, failure, and ready states are turned into elements.
|
||||
*
|
||||
* Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the
|
||||
* failure text is always Signage's own sanitized message, never a caught exception.
|
||||
*/
|
||||
export function renderLoadable<Value>(
|
||||
state: Loadable<Value>,
|
||||
render: (value: Value) => ReactNode,
|
||||
options: { readonly label: string; readonly onRetry?: () => void },
|
||||
): ReactNode {
|
||||
if (state.status === "loading") {
|
||||
return <LoadingState label={options.label} />;
|
||||
}
|
||||
if (state.status === "failed") {
|
||||
return (
|
||||
<ErrorState
|
||||
action={
|
||||
options.onRetry === undefined ? undefined : (
|
||||
<button
|
||||
className="ek-button ek-button--secondary"
|
||||
onClick={options.onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)
|
||||
}
|
||||
description={state.failure.message}
|
||||
title="Signage could not load this"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return render(state.value);
|
||||
}
|
||||
|
||||
/** A refused save, shown where the operator was working. The server's own sanitized message and
|
||||
* nothing else: no status line, no URL, no request detail. */
|
||||
export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) {
|
||||
if (!failure) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<p className="signage-failure" role="alert">
|
||||
{failure.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1000;
|
||||
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatInstant(value: string | number | null, timezone: string): string {
|
||||
if (value === null) {
|
||||
return "—";
|
||||
}
|
||||
const date = typeof value === "number" ? new Date(value) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "—";
|
||||
}
|
||||
// Always in the installation's own zone, and always labelled with it. A church reading "9:00"
|
||||
// needs to know which nine o'clock, and a browser's local zone is not the answer.
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: timezone,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatSince(atMs: number | null, nowMs: number): string {
|
||||
if (atMs === null) {
|
||||
return "never";
|
||||
}
|
||||
const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
||||
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
* The Signage management UI's own stylesheet.
|
||||
*
|
||||
* Compiled into the declared browser artifact as text and installed by the host into this artifact's
|
||||
* isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing
|
||||
* outside Signage reaches in.
|
||||
*
|
||||
* Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from
|
||||
* one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and
|
||||
* the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's
|
||||
* output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content
|
||||
* belongs to whoever made it.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The box model for the tree Signage owns.
|
||||
*
|
||||
* An isolated root inherits nothing from the page around it, and the platform's stylesheet declares
|
||||
* no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`,
|
||||
* is laid out wider than the field containing it by its own padding and border, and slides under
|
||||
* whatever sits beside it. Setting the box model once here fixes that everywhere, rather than
|
||||
* subtracting padding by hand in each panel.
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--signage-gap: var(--ekklesia-spacing-lg);
|
||||
--signage-gap-tight: var(--ekklesia-spacing-sm);
|
||||
--signage-panel-radius: var(--ekklesia-radius-lg);
|
||||
--signage-thumbnail-width: 220px;
|
||||
}
|
||||
|
||||
.signage-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signage-narrow-notice {
|
||||
background: var(--ekklesia-background-surface);
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-base);
|
||||
border-radius: var(--signage-panel-radius);
|
||||
color: var(--ekklesia-text-primary);
|
||||
display: grid;
|
||||
font-family: var(--ekklesia-typography-family-sans);
|
||||
gap: var(--signage-gap-tight);
|
||||
padding: var(--ekklesia-spacing-xl);
|
||||
}
|
||||
|
||||
.signage-narrow-notice__title {
|
||||
font-size: var(--ekklesia-typography-size-heading-3);
|
||||
font-weight: var(--ekklesia-typography-weight-bold);
|
||||
line-height: var(--ekklesia-typography-line-height-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-narrow-notice__body {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
line-height: var(--ekklesia-typography-line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.signage-narrow-notice {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signage-desktop {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.signage-columns {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-inline-form {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
/*
|
||||
* A labelled control beside the button that submits it. The field takes the width its control asks
|
||||
* for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding
|
||||
* the button on top of it.
|
||||
*/
|
||||
.signage-inline-form > .ek-field {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signage-inline-form .ek-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* On one line, a field's hint hangs below the row rather than making that one field taller. The row
|
||||
* is bottom-aligned so the controls share a line with the button, and a hint left in flow would push
|
||||
* its own control above the others.
|
||||
*/
|
||||
.signage-inline-form > .ek-field > .ek-field__hint {
|
||||
inset-block-start: 100%;
|
||||
inset-inline-start: 0;
|
||||
padding-block-start: var(--ekklesia-spacing-2xs);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-inline-form:has(.ek-field__hint) {
|
||||
padding-block-end: var(--ekklesia-spacing-lg);
|
||||
}
|
||||
|
||||
.signage-failure {
|
||||
color: var(--ekklesia-text-danger);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
margin: var(--signage-gap-tight) 0 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A list row that carries a name beside its metadata. The platform's list item is a plain block, so
|
||||
* without this the two run together into one unreadable word — and the row's own children already
|
||||
* assume a flex line.
|
||||
*/
|
||||
.signage-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
.signage-row__name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Images.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-upload-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.signage-upload-input {
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.signage-uploads {
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
margin-bottom: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-uploads__message {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
.signage-image-grid {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signage-image {
|
||||
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
|
||||
than spread between the thumbnail and the name. */
|
||||
align-content: start;
|
||||
background: var(--ekklesia-background-canvas);
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
overflow: hidden;
|
||||
padding: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
|
||||
a tall image is letterboxed here rather than reflowing the card it sits in. */
|
||||
.signage-image__thumbnail {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--ekklesia-background-inset);
|
||||
border-radius: var(--ekklesia-radius-sm);
|
||||
display: block;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-image__missing {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--ekklesia-background-inset);
|
||||
border-radius: var(--ekklesia-radius-sm);
|
||||
color: var(--ekklesia-text-muted);
|
||||
display: flex;
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signage-image__meta {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* A badge is sized by what it says, not by the column it lands in. */
|
||||
.signage-image__expiry {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.signage-image__name {
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.signage-image__detail {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-image__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Timelines.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-editor {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-editor__title {
|
||||
font-size: var(--ekklesia-typography-size-heading-3);
|
||||
font-weight: var(--ekklesia-typography-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-editor__subtitle {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.signage-editor__add,
|
||||
.signage-editor__sources,
|
||||
.signage-editor__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-editor__sources {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
}
|
||||
|
||||
.signage-editor__source {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-editor__order {
|
||||
display: flex;
|
||||
gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Playback.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-playback {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-playback__status,
|
||||
.signage-playback__controls,
|
||||
.signage-playback__activate {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-playback__current {
|
||||
column-gap: var(--signage-gap);
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
margin: 0;
|
||||
row-gap: var(--ekklesia-spacing-2xs);
|
||||
}
|
||||
|
||||
.signage-playback__current dt {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
.signage-playback__current dd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-playback__history-meta {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Displays and providers.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-groups {
|
||||
display: grid;
|
||||
gap: var(--signage-gap);
|
||||
}
|
||||
|
||||
.signage-group {
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
padding: var(--ekklesia-spacing-md);
|
||||
}
|
||||
|
||||
.signage-group__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-provider__id {
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
}
|
||||
|
||||
.signage-provider__version {
|
||||
color: var(--ekklesia-text-muted);
|
||||
font-size: var(--ekklesia-typography-size-sm);
|
||||
/* Far enough that the version reads as a second word, not the tail of the identifier. */
|
||||
margin-left: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------------
|
||||
* Live preview.
|
||||
*
|
||||
* The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the
|
||||
* display's own scene layers render exactly as they do on a wall — this panel supplies a size and
|
||||
* nothing else.
|
||||
* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
.signage-preview {
|
||||
display: grid;
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-preview__frame {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000000;
|
||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-stage {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transition-duration: 0ms;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: linear;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__background {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__background-image {
|
||||
display: none;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
transform: scale(1.08);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__darken {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__foreground {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-layer__renderer {
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.signage-preview .signage-blackout {
|
||||
background: #000000;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 200ms linear;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.signage-preview [data-blackout="true"] > .signage-blackout {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate-host {
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.signage-preview [data-slate="true"] > .signage-slate-host {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bars {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--one {
|
||||
background: #6f6f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--two {
|
||||
background: #6f6f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--three {
|
||||
background: #2f6f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--four {
|
||||
background: #2f6f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--five {
|
||||
background: #6f2f6f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__bar--six {
|
||||
background: #6f2f2f;
|
||||
}
|
||||
|
||||
.signage-preview .signage-slate__message {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
flex: 0 0 auto;
|
||||
font-size: 2vh;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
padding: 1.5vh 0;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The wire shapes Signage's dashboard reads.
|
||||
*
|
||||
* Signage owns both ends of every one of these, which is exactly why they are written down: the
|
||||
* server's route and this file are one contract, and a drift between them is a bug an operator sees
|
||||
* rather than a compiler does.
|
||||
*/
|
||||
|
||||
export interface SettingsResponse {
|
||||
readonly crossfadeMs: number;
|
||||
readonly moduleVersion: string;
|
||||
readonly nativeImageDurationMs: number;
|
||||
readonly nativePresentation: {
|
||||
readonly backgroundMode: string;
|
||||
readonly backgroundColor: string;
|
||||
readonly backgroundDarkened: boolean;
|
||||
readonly blurPixels: number;
|
||||
};
|
||||
readonly rendererInputVersion: string;
|
||||
readonly timezone: string;
|
||||
readonly uploads: {
|
||||
readonly batchMaxFiles: number;
|
||||
readonly concurrency: number;
|
||||
readonly maxBytes: number;
|
||||
readonly maxPixels: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageRendition {
|
||||
readonly kind: string;
|
||||
readonly url: string;
|
||||
readonly expiresAt: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly originalFilename: string;
|
||||
readonly contentType: string;
|
||||
readonly byteSize: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly status: string;
|
||||
readonly expiresAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly renditions: readonly ImageRendition[];
|
||||
}
|
||||
|
||||
export interface TimelineSummary {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly eligibleFrom: string | null;
|
||||
readonly eligibleUntil: string | null;
|
||||
readonly revision: number;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TimelineSource {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly providerId: string;
|
||||
readonly sourceId: string;
|
||||
}
|
||||
|
||||
export interface TimelineSlot {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly kind: "provider-source" | "pinned-item";
|
||||
readonly sourceId: string | null;
|
||||
readonly providerId: string | null;
|
||||
readonly itemId: string | null;
|
||||
readonly enabled: boolean;
|
||||
readonly durationOverrideMs: number | null;
|
||||
readonly eligibleFrom: string | null;
|
||||
readonly eligibleUntil: string | null;
|
||||
readonly presentation: {
|
||||
readonly backgroundMode: string | null;
|
||||
readonly backgroundColor: string | null;
|
||||
readonly backgroundDarkened: boolean | null;
|
||||
readonly blurPixels: number | null;
|
||||
readonly foregroundShadow: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineDetail {
|
||||
readonly timeline: TimelineSummary;
|
||||
readonly sources: readonly TimelineSource[];
|
||||
readonly slots: readonly TimelineSlot[];
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
readonly providerId: string;
|
||||
readonly providerVersion: string;
|
||||
readonly contractVersion: string;
|
||||
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
|
||||
readonly defaultDurationMs: number;
|
||||
readonly registeredAt: string;
|
||||
readonly consecutiveFailures: number;
|
||||
readonly lastFailureCode: string | null;
|
||||
readonly lastFailureAt: string | null;
|
||||
readonly lastSuccessAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderSourceDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
export interface ProviderItemDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
readonly availableUntil?: string | null;
|
||||
}
|
||||
|
||||
export type TransportState = "idle" | "playing" | "paused" | "static";
|
||||
|
||||
export interface PlaybackSnapshotView {
|
||||
readonly groupId: string;
|
||||
readonly revision: number;
|
||||
readonly serverNowMs: number;
|
||||
readonly transport: TransportState;
|
||||
readonly blackout: boolean;
|
||||
readonly activeTimelineId: string | null;
|
||||
readonly pendingTimelineId: string | null;
|
||||
readonly scheduledTimelineId: string | null;
|
||||
readonly scheduledActivationAtMs: number | null;
|
||||
readonly currentSelectionId: string | null;
|
||||
readonly currentSlotId: string | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
readonly remainingMs: number | null;
|
||||
readonly selectionRevision: number | null;
|
||||
readonly staticAssetId: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelView {
|
||||
readonly id: string;
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelGroupView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly revision: number;
|
||||
readonly channels: readonly ChannelView[];
|
||||
readonly playback: PlaybackSnapshotView;
|
||||
}
|
||||
|
||||
export interface PlaybackDetailView {
|
||||
readonly snapshot: PlaybackSnapshotView;
|
||||
readonly current: {
|
||||
readonly id: string;
|
||||
readonly itemId: string;
|
||||
readonly itemRevision: string;
|
||||
readonly providerId: string;
|
||||
readonly rendererContributionId: string | null;
|
||||
readonly revision: number;
|
||||
readonly stale: boolean;
|
||||
readonly withdrawn: boolean;
|
||||
readonly durationMs: number;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly deadlineAtMs: number | null;
|
||||
} | null;
|
||||
readonly history: readonly {
|
||||
readonly id: string;
|
||||
readonly itemId: string;
|
||||
readonly providerId: string;
|
||||
readonly sequence: number;
|
||||
readonly startedAtMs: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ViewerHealthView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly online: boolean;
|
||||
readonly channelId: string | null;
|
||||
readonly deviceId: string | null;
|
||||
readonly lastSeenAtMs: number | null;
|
||||
readonly reportedTimelineId: string | null;
|
||||
readonly reportedSelectionId: string | null;
|
||||
readonly reportedProviderId: string | null;
|
||||
readonly reportedContributionId: string | null;
|
||||
readonly offlineReady: boolean;
|
||||
readonly cachedAssetCount: number;
|
||||
readonly lastError: {
|
||||
readonly code: string;
|
||||
readonly detail: string | null;
|
||||
readonly atMs: number;
|
||||
} | null;
|
||||
}
|
||||
Reference in New Issue
Block a user