358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import {
|
|
ActionBar,
|
|
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));
|
|
}
|
|
|
|
function timelineLabel(timelineId: string): string {
|
|
if (timelines.state.status !== "ready") {
|
|
return timelineId;
|
|
}
|
|
return (
|
|
timelines.state.value.timelines.find(
|
|
(timeline) => timeline.id === timelineId,
|
|
)?.name ?? timelineId
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="signage-columns signage-playback-layout">
|
|
<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">
|
|
{value.snapshot.activeTimelineId === null ? undefined : (
|
|
<Badge tone="success">
|
|
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
|
|
</Badge>
|
|
)}
|
|
<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"
|
|
/>
|
|
) : (
|
|
<section className="signage-playback__now">
|
|
<h3>Now showing</h3>
|
|
<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>
|
|
</section>
|
|
)}
|
|
|
|
{canControl ? (
|
|
<ActionBar
|
|
className="signage-playback__controls"
|
|
data-testid="signage-controls"
|
|
label="Playback 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>
|
|
</ActionBar>
|
|
) : 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>
|
|
);
|
|
}
|