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