Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
|
import { type ReactElement, useEffect, useRef, 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"),
|
|
[],
|
|
"channel-groups",
|
|
);
|
|
const timelines = useResource(
|
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
|
[],
|
|
"timelines",
|
|
);
|
|
const detail = useResource(
|
|
() =>
|
|
groupId === undefined
|
|
? Promise.resolve(undefined)
|
|
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
|
[groupId],
|
|
groupId === undefined ? undefined : `playback/${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);
|
|
useManagementUpdates(context, groupId, () => {
|
|
detail.reload();
|
|
groups.reload();
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks
|
|
* for fresh detail when this group's playback actually changed — so "Now showing" follows the wall
|
|
* instead of freezing until an operator presses something. One subscription per mounted panel,
|
|
* closed when the panel goes away.
|
|
*/
|
|
function useManagementUpdates(
|
|
context: ModuleUiHostContext,
|
|
groupId: string | undefined,
|
|
onChange: () => void,
|
|
): void {
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
useEffect(() => {
|
|
if (groupId === undefined) {
|
|
return;
|
|
}
|
|
let seen: string | undefined;
|
|
const subscription = context.realtime.subscribe("management-state", {
|
|
onSnapshot: (payload) => observe(payload),
|
|
onUpdate: (payload) => observe(payload),
|
|
});
|
|
function observe(payload: unknown): void {
|
|
const groups = (payload as { groups?: unknown } | null)?.groups;
|
|
if (!Array.isArray(groups)) {
|
|
return;
|
|
}
|
|
const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as
|
|
| { revision?: unknown; selectionRevision?: unknown }
|
|
| undefined;
|
|
if (!mine) {
|
|
return;
|
|
}
|
|
const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`;
|
|
if (seen !== undefined && marker !== seen) {
|
|
onChangeRef.current();
|
|
}
|
|
seen = marker;
|
|
}
|
|
return () => subscription.close();
|
|
}, [context, groupId]);
|
|
}
|