import { Badge, Button, Field, InlineNotice, Panel, Select, 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 { ConfirmButton, FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; /** * The key content providers use to pick an audience's version of a slide, derived from the name an * operator typed. It is an implementation detail: staff name an audience ("Online"), and only an * operator who knows a provider expects a particular key needs the Advanced override. */ export function audienceKeyFor(name: string, taken: readonly string[]): string { const base = name .normalize("NFKD") .toLowerCase() .replace(/[^a-z0-9]+/gu, "-") .replace(/^-+|-+$/gu, "") .slice(0, 48) .replace(/-+$/u, "") || "audience"; const start = /^[a-z]/u.test(base) ? base : `audience-${base}`; const reserved = new Set([...taken, "default"]); let candidate = start; for (let suffix = 2; reserved.has(candidate); suffix += 1) { candidate = `${start}-${suffix}`; } return candidate; } function DisplayRow({ canManage, groups, nowMs, onChannelChange, onNameChange, onRemove, viewer, }: { readonly canManage: boolean; readonly groups: readonly ChannelGroupView[]; readonly nowMs: number; readonly onChannelChange: (channelId: string) => void; readonly onNameChange: (name: string) => void; readonly onRemove: () => void; readonly viewer: ViewerHealthView; }): ReactElement { return (
{canManage ? ( event.target.value !== viewer.name && onNameChange(event.target.value) } /> ) : ( {viewer.name} )} {viewer.deviceId === null ? ( // Opened by someone signed in — a test tab on a laptop, not a paired screen. Signed-in browser ) : undefined}
{canManage ? ( {(control) => ( )} ) : undefined}
{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} {viewer.lastError === null ? undefined : ( {viewer.lastError.code} )} {canManage && !viewer.online ? ( // Only an offline entry: a screen that is still connected would simply reappear. ) : undefined}
); } /** * Channel groups, audience channels, and displays, presented in the same hierarchy they use at run * time: one group clock, one audience variant per channel, then the screens assigned to it. */ export function DisplaysPanel({ api, canManage, copyText, displaySetupUrl, onGroupsChanged, }: { readonly api: SignageApi; readonly canManage: boolean; readonly copyText: (value: string) => Promise; readonly displaySetupUrl: string | undefined; readonly onGroupsChanged?: () => void; }): ReactElement { const groups = useResource( () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), [], "channel-groups", ); const viewers = useResource( () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), [], "viewers", ); const edit = useAction(() => { groups.reload(); viewers.reload(); onGroupsChanged?.(); }); const viewerEdit = useAction(viewers.reload); const [groupName, setGroupName] = useState(""); const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle"); const [confirmGroupId, setConfirmGroupId] = useState(undefined); const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string; }>({ groupId: "", key: "", name: "", }); useTick(5000); const nowMs = Date.now(); const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; const updateViewer = (viewerId: string, changes: Record): void => { viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); }; const removeViewer = (viewerId: string): void => { viewerEdit.run(() => api.send("DELETE", `viewers/${viewerId}`)); }; return (
Preview here
) } description="Connect a screen in three short steps." title="Pair a display" >
  1. 1 On the screen that will show signage, open the display setup address. Copy it from here; opening it on this computer only previews the display.
  2. 2 Enter its six-digit code under Display devices in the Dashboard sidebar.
  3. 3 Name the display and choose its group below.
{displaySetupUrl === undefined ? ( This host did not provide a display setup address. ) : undefined} {copyStatus === "copied" ? "Display setup address copied." : copyStatus === "failed" ? "Could not copy the address. Use Preview here and copy it from the address bar." : ""} {viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
No displays paired yet A newly approved screen appears below in its assigned channel.
) : undefined} {(control) => ( setGroupName(event.target.value)} placeholder="Nursery hallway" value={groupName} /> )} ) : undefined } description="Displays in one group play together, on the same slide at the same moment. Use another group for screens that should run their own loop." title="Channel groups and displays" > {renderLoadable( groups.state, (value) => { const knownChannelIds = new Set( value.groups.flatMap((group) => group.channels.map((channel) => channel.id)), ); const unassigned = readyViewers.filter( (viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), ); return (
{value.groups.map((group) => (
Channel group

{group.name}

{group.playback.transport} {canManage && value.groups.length > 1 ? ( ) : canManage ? ( Required group ) : undefined}
{confirmGroupId === group.id ? (

Delete {group.name}? Its playback history will be removed. Displays assigned here will move to the default channel of another group.

) : undefined}
Audiences

Who is watching these displays

Every display in this group shows the same slide at the same moment. Add an audience only when some screens should get a different version of it, such as wording for people watching online. Images look the same for every audience.
{group.channels.map((channel) => { const assigned = readyViewers.filter( (viewer) => viewer.channelId === channel.id, ); return (
{channel.name}
{channel.isDefault ? ( Default audience ) : undefined} {`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
{canManage && !channel.isDefault ? ( edit.run(() => api.send("DELETE", `channels/${channel.id}`)) } testId={`signage-channel-remove-${channel.id}`} /> ) : undefined}
Displays {assigned.length === 0 ? (

No displays assigned.

) : ( assigned.map((viewer) => ( updateViewer(viewer.id, { channelId }) } onNameChange={(name) => updateViewer(viewer.id, { name })} onRemove={() => removeViewer(viewer.id)} viewer={viewer} /> )) )}
); })}
{canManage ? (
Add an audience
The default audience always remains.
{(control) => ( setChannelDraft((current) => ({ groupId: group.id, key: current.groupId === group.id ? current.key : "", name: event.target.value, })) } placeholder="Online" value={channelDraft.groupId === group.id ? channelDraft.name : ""} /> )}
Advanced channel.key), )}.`} label="Content key" > {(control) => ( setChannelDraft((current) => ({ groupId: group.id, key: event.target.value, name: current.groupId === group.id ? current.name : "", })) } pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" value={channelDraft.groupId === group.id ? channelDraft.key : ""} /> )}
) : undefined}
))} {unassigned.length === 0 ? undefined : (
Needs assignment {unassigned.map((viewer) => ( updateViewer(viewer.id, { channelId })} onNameChange={(name) => updateViewer(viewer.id, { name })} onRemove={() => removeViewer(viewer.id)} viewer={viewer} /> ))}
)}
); }, { label: "Loading channel groups…", onRetry: groups.reload }, )} {renderLoadable(viewers.state, () => null, { label: "Loading displays…", onRetry: viewers.reload, })}
); }