diff --git a/src/browser/desktop-gate.tsx b/src/browser/desktop-gate.tsx
index 0af2331..9df9423 100644
--- a/src/browser/desktop-gate.tsx
+++ b/src/browser/desktop-gate.tsx
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
Signage needs a desktop screen
- {`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
+ {`Open this page on a screen at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, displays, and playback.`}
diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx
index 5a49bdc..7260f12 100644
--- a/src/ui/SignageDashboard.tsx
+++ b/src/ui/SignageDashboard.tsx
@@ -1,9 +1,9 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
-import { type ReactElement, useMemo, useState } from "react";
+import { type ReactElement, useContext, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js";
-import { useResource } from "./hooks.js";
+import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
@@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/
-export function SignageDashboard({
- context,
- copyText,
-}: {
- readonly context: ModuleUiHostContext;
- readonly copyText: (value: string) => Promise;
+export function SignageDashboard(props: {
+ readonly context: ModuleUiHostContext;
+ readonly copyText: (value: string) => Promise;
}): ReactElement {
- const api = useMemo(() => createSignageApi(context.api), [context.api]);
- // Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
- // A hidden screen is a usability choice; the server is what actually refuses, and making a
- // request it will certainly refuse is noise in a log rather than security.
- const mayView = context.permissions.has("signage.view");
- const settings = useResource(
- () =>
- mayView
- ? api.get("settings")
- : Promise.resolve(undefined),
- [mayView],
- );
- const groups = useResource(
- () =>
- mayView
- ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
- : Promise.resolve(undefined),
- [mayView],
- );
- const [tab, setTab] = useState("playback");
- const [groupId, setGroupId] = useState(undefined);
+ // One cache for the life of this screen: everything the panels have loaded, shared between them.
+ const [cache] = useState(createResourceCache);
+ return (
+
+
+
+ );
+}
- const mayManageAssets = context.permissions.has("signage.assets.manage");
- const mayManageTimelines = context.permissions.has(
- "signage.timelines.manage",
- );
- const mayControl = context.permissions.has("signage.playback.control");
- const mayManageDisplays = context.permissions.has("signage.displays.manage");
+function SignageWorkspace({
+ context,
+ copyText,
+}: {
+ readonly context: ModuleUiHostContext;
+ readonly copyText: (value: string) => Promise;
+}): ReactElement {
+ const api = useMemo(() => createSignageApi(context.api), [context.api]);
+ const cache = useContext(ResourceCacheContext);
+ // Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
+ // A hidden screen is a usability choice; the server is what actually refuses, and making a
+ // request it will certainly refuse is noise in a log rather than security.
+ const mayView = context.permissions.has("signage.view");
+ const settings = useResource(
+ () => (mayView ? api.get("settings") : Promise.resolve(undefined)),
+ [mayView],
+ mayView ? "settings" : undefined,
+ );
+ const groups = useResource(
+ () =>
+ mayView
+ ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
+ : Promise.resolve(undefined),
+ [mayView],
+ mayView ? "channel-groups" : undefined,
+ );
+ const [tab, setTab] = useState("playback");
+ const [groupId, setGroupId] = useState(undefined);
- if (!mayView) {
- return (
-
- );
- }
+ const mayManageAssets = context.permissions.has("signage.assets.manage");
+ const mayManageTimelines = context.permissions.has("signage.timelines.manage");
+ const mayControl = context.permissions.has("signage.playback.control");
+ const mayManageDisplays = context.permissions.has("signage.displays.manage");
- const selectedGroupId =
- groupId ??
- (groups.state.status === "ready"
- ? groups.state.value?.groups[0]?.id
- : undefined);
- const selectedGroup =
- groups.state.status === "ready"
- ? groups.state.value?.groups.find(
- (candidate) => candidate.id === selectedGroupId,
- )
- : undefined;
+ if (!mayView) {
+ return (
+
+ );
+ }
- return (
- <>
-
-
-
- {renderLoadable(
- settings.state,
- (loaded) =>
- loaded === undefined ? undefined : (
-
+ )
+ }
+ description="Connect a screen in three short steps."
+ title="Pair a display"
+ >
+
+
+ 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
+ Enter its six-digit code under Display devices in the Dashboard sidebar.
+
+
+ 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
+ }
+ 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 (
+
+
+ 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.
+
+
+ return (
+ Close}
+ description="Slots run in order and repeat. Repeating one source is one rotation, not several."
+ title="Timeline pattern"
+ >
+ {renderLoadable(
+ detail.state,
+ (value) => (
+