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(`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 (
( {(control) => ( )} ), { label: "Loading groups…" }, )} description="One clock for every channel in the group." title="Playback" > {groupId === undefined ? ( ) : ( renderLoadable( detail.state, (value) => value === undefined ? ( ) : (
{value.snapshot.activeTimelineId === null ? undefined : ( {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} )} {value.snapshot.transport} {value.snapshot.blackout ? Blackout : undefined} {value.current?.stale === true ? ( Stale ) : undefined} {value.current?.withdrawn === true ? ( Withdrawn ) : undefined} {value.snapshot.pendingTimelineId === null ? undefined : ( Activation queued )}
{value.current === null ? ( ) : (

Now showing

Provider
{value.current.providerId}
Item
{value.current.itemId}
Renderer
{value.current.rendererContributionId ?? "—"}
Duration
{formatDuration(value.current.durationMs)}
Remaining
{value.snapshot.remainingMs === null ? "—" : formatDuration(value.snapshot.remainingMs)}
Started
{formatInstant(value.current.startedAtMs, settings.timezone)}
)} {canControl ? ( {value.snapshot.transport === "paused" ? ( ) : ( )} {value.snapshot.staticAssetId === null ? undefined : ( )} ) : undefined} {canControl ? (
{renderLoadable( timelines.state, (loaded) => ( <> {(control) => ( )} ), { label: "Loading timelines…" }, )}
) : undefined} {value.history.length === 0 ? undefined : (

Recently played

    {value.history.slice(0, 8).map((entry) => (
  • {entry.itemId} {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
  • ))}
)}
), { label: "Loading playback…", onRetry: detail.reload }, ) )}
); } /** * 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]); }