Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97de441b5f | ||
|
|
7c17282616 | ||
|
|
67d89f1020 | ||
|
|
6bf9167f85 | ||
|
|
86d49846ff | ||
|
|
a8c35db201 | ||
|
|
91bdba3136 | ||
|
|
86121d7931 | ||
|
|
8a7e96dd61 |
@@ -0,0 +1,82 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for Claude Code (and other coding agents) working in this repository. It points at
|
||||||
|
authoritative sources rather than restating them, because restated details drift. Inspect the actual
|
||||||
|
source, tests, `package.json`, and CI workflow before relying on anything here or on memory of a
|
||||||
|
prior session.
|
||||||
|
|
||||||
|
## What this repository is
|
||||||
|
|
||||||
|
Signage is a first-party Ekklesia **module** in its own repository (`Zayin/Signage`). It is not part of
|
||||||
|
the platform monorepo and vendors none of it: it builds against the published `@ekklesia/*`
|
||||||
|
module-development packages (platform ADRs 0053 and 0054), ships as one Ekklesia Module Package
|
||||||
|
(ADR 0049), and reaches PostgreSQL only through the Module Database ABI v1 executor the host hands it
|
||||||
|
(ADR 0050). Read `README.md` for what exists and the scope boundaries.
|
||||||
|
|
||||||
|
Signage owns its domain: channel groups, audience channels, timelines, slots, signage assignments,
|
||||||
|
playback rules, native images, and **its own management and display UI**. The platform shell hosts
|
||||||
|
that UI through the generic module loader and never rebuilds it; do not move Signage screens into the
|
||||||
|
platform.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them in.
|
||||||
|
They are authoritative for product direction. If this repository's documentation disagrees with them,
|
||||||
|
flag the conflict and correct the repository-local documentation; never silently follow stale text.
|
||||||
|
|
||||||
|
Rules from those documents that apply to every change here:
|
||||||
|
|
||||||
|
- **Shared resources belong to the platform.** Displays/devices, identity, branding, mail, storage,
|
||||||
|
and module lifecycle are the platform's. Signage *consumes* displays; it assigns content to them.
|
||||||
|
- **The current display management is transitional.** Signage's viewer roster, display naming, and
|
||||||
|
pairing instructions, together with the platform's sidebar *Display devices* panel, are what exists
|
||||||
|
today. Stage 1 moves pairing and the one global display name to the platform's
|
||||||
|
**Settings → Displays**, and Signage then selects an existing display. Fix defects in the current
|
||||||
|
flow, keep existing paired displays, viewer identities, cached content, and assignments working, and
|
||||||
|
do **not** implement the Stage 1 Settings/Displays architecture early.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia" (platform ADR 0046).
|
||||||
|
- **Visual direction.** Simple and minimal; responsive; light, dark, and system modes. Normal UI
|
||||||
|
colours come only from the shared design tokens (`@ekklesia/design-tokens`) and shared elements
|
||||||
|
(`@ekklesia/ui`) — never a hard-coded colour. The display artifact's pure-black stage, blackout,
|
||||||
|
and slate are content presentation, not themed UI.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited.
|
||||||
|
Destructive actions use in-product confirmation proportional to impact: a two-step in-place
|
||||||
|
confirmation for moderate actions, the shared `Dialog` for significant ones.
|
||||||
|
- **Plain language.** Normal church staff must understand the UI without implementation terms; do not
|
||||||
|
expose an internal identifier merely because it exists.
|
||||||
|
|
||||||
|
## ADRs
|
||||||
|
|
||||||
|
`docs/architecture/decisions/` holds Signage's own ADRs; the platform's ADRs live in the Ekklesia
|
||||||
|
repository. Accepted ADRs are immutable except for status and cross-reference corrections. A changed
|
||||||
|
decision gets a new ADR that supersedes the old one; never rewrite a historical ADR to fit a change.
|
||||||
|
|
||||||
|
## Working conventions
|
||||||
|
|
||||||
|
- Canonical remote: `ssh://[email protected]:2222/Zayin/Signage.git` (the platform is
|
||||||
|
`Zayin/Ekklesia.git`). Local clones can be behind: `git fetch --prune --tags` and branch from
|
||||||
|
`origin/main`.
|
||||||
|
- One branch per PR off an up-to-date `origin/main`. Never push to `main`, never merge your own PR,
|
||||||
|
and never create or push a release tag — release tags belong to the human maintainer.
|
||||||
|
- A fix that needs a platform contract change is two PRs: the Ekklesia change first, then Signage,
|
||||||
|
with the compatibility boundary explicit. Do not copy platform logic into Signage to avoid it.
|
||||||
|
- Every defect fix gets a regression test. Signage never reads the environment, opens its own socket,
|
||||||
|
builds an Ekklesia URL, or applies a migration; all of that reaches it through the host.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`package.json` and `.gitea/workflows/ci.yml` are the source of truth. From a clean checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm validate
|
||||||
|
bash ci/check-cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The CI `integration` job installs the real Module Package into the pinned platform
|
||||||
|
(`ci/platform.env`) and drives the Dashboard and a kiosk display in real browsers, running the display
|
||||||
|
scenarios listed in `ci/display-scenarios` (`bash ci/package.sh`, `bash ci/fetch-platform.sh`,
|
||||||
|
`bash ci/integration.sh`; it needs Docker). Unit tests passing is not evidence that a display or
|
||||||
|
browser behaviour works: verify display behaviour through the integration harness.
|
||||||
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.3 is feature-complete for its declared scope, and Signage has run in production since
|
Version 0.1.4 is feature-complete for its declared scope, and Signage has run in production since
|
||||||
0.1.1. What exists today:
|
0.1.1. What exists today:
|
||||||
|
|
||||||
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
|
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
|
||||||
@@ -139,8 +139,25 @@ description of the platform ABI.
|
|||||||
| Transitions, offline continuation, playback controls | Dynamic change within one slide |
|
| Transitions, offline continuation, playback controls | Dynamic change within one slide |
|
||||||
| The management and display UI | Its own internal multi-stage behaviour |
|
| The management and display UI | Its own internal multi-stage behaviour |
|
||||||
|
|
||||||
|
## Displays today, and what Stage 1 changes
|
||||||
|
|
||||||
|
Displays are platform-owned. What exists now is transitional: a screen is paired in the Dashboard
|
||||||
|
sidebar's *Display devices* panel, and Signage keeps its own roster of the screens that have checked
|
||||||
|
in — their names, the group and audience each is assigned to, and their health. A running display
|
||||||
|
follows its assignment as soon as an administrator changes it, and a revoked display returns to its
|
||||||
|
pairing code. Entries for screens that are offline (a retired television, a test tab) can be removed
|
||||||
|
from the roster.
|
||||||
|
|
||||||
|
The product roadmap moves pairing and the one global display name to the platform's
|
||||||
|
**Settings → Displays** in Stage 1; Signage will then select an existing display instead of naming its
|
||||||
|
own. Until then, existing paired displays, their viewer identities, cached content, and assignments are
|
||||||
|
preserved across every Signage upgrade.
|
||||||
|
|
||||||
|
The management screen is currently desktop-only (the narrow-screen notice below 1024 px). The product
|
||||||
|
design requires phone and tablet administration and dark mode; that arrives with the Stage 1 shared
|
||||||
|
responsive Dashboard and design system rather than as a Signage-only layout.
|
||||||
|
|
||||||
## Licence and scope
|
## Licence and scope
|
||||||
|
|
||||||
Deployment, the module store, a theme editor, a mobile management UI, video and audio, MQTT, Home
|
Deployment, the module store, a theme editor, video and audio, MQTT, Home Assistant, Companion, and any
|
||||||
Assistant, Companion, and any migration of legacy Announcements data are explicitly out of scope for
|
migration of legacy Announcements data are explicitly out of scope for this repository.
|
||||||
this repository.
|
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ two-pages
|
|||||||
session-blip
|
session-blip
|
||||||
stream-failure
|
stream-failure
|
||||||
stream-recycle
|
stream-recycle
|
||||||
|
connectivity-blip
|
||||||
|
caching-probe
|
||||||
|
revoke
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# The companion Core harness Signage uses to install and run its real external package. The pull ref
|
# The companion Core harness Signage uses to install and run its real external package. The pull ref
|
||||||
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
|
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
|
||||||
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
|
EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git
|
||||||
EKKLESIA_PLATFORM_REF=refs/pull/90/head
|
EKKLESIA_PLATFORM_REF=refs/pull/92/head
|
||||||
EKKLESIA_PLATFORM_COMMIT=40ef0ff802c124ae5a3a0b9aa51a1502a7b72644
|
EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"id": "signage",
|
"id": "signage",
|
||||||
"name": "Signage",
|
"name": "Signage",
|
||||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.2.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.2.0"
|
"moduleSdk": "^0.2.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/signage",
|
"name": "@ekklesia/signage",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
|
|||||||
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
|
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
|
||||||
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
|
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
|
||||||
<p className="signage-narrow-notice__body">
|
<p className="signage-narrow-notice__body">
|
||||||
{`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.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="signage-desktop" data-testid="signage-desktop">
|
<div className="signage-desktop" data-testid="signage-desktop">
|
||||||
|
|||||||
+153
-21
@@ -20,6 +20,7 @@ import {
|
|||||||
signageMediaKey,
|
signageMediaKey,
|
||||||
} from "../browser/storage.js";
|
} from "../browser/storage.js";
|
||||||
import {
|
import {
|
||||||
|
assignedChannelOf,
|
||||||
type DisplayChannelState,
|
type DisplayChannelState,
|
||||||
type DisplayHeartbeat,
|
type DisplayHeartbeat,
|
||||||
type DisplayRealtimeGroup,
|
type DisplayRealtimeGroup,
|
||||||
@@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
let stage: SceneStage | undefined;
|
let stage: SceneStage | undefined;
|
||||||
let session: DisplaySessionView | undefined;
|
let session: DisplaySessionView | undefined;
|
||||||
let subscription: { close(): void } | undefined;
|
let subscription: { close(): void } | undefined;
|
||||||
|
/** Identifies the one subscription whose callbacks may still act. */
|
||||||
|
let activeSubscription: symbol | undefined;
|
||||||
let heartbeatHandle: unknown;
|
let heartbeatHandle: unknown;
|
||||||
let refreshHandle: unknown;
|
let refreshHandle: unknown;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
* each resets once its request works again. */
|
* each resets once its request works again. */
|
||||||
let sessionHandle: unknown;
|
let sessionHandle: unknown;
|
||||||
let sessionAttempt = 0;
|
let sessionAttempt = 0;
|
||||||
|
/** One registration in flight at a time. A refresh and a heartbeat can both discover that this
|
||||||
|
* viewer was removed from the roster; registering twice would open two realtime subscriptions and
|
||||||
|
* two heartbeat timers for one screen. */
|
||||||
|
let registering = false;
|
||||||
|
/** Consecutive times this screen found itself removed from the roster without a working fetch in
|
||||||
|
* between. Resets once a state fetch succeeds. */
|
||||||
|
let rosterAttempt = 0;
|
||||||
let refreshAttempt = 0;
|
let refreshAttempt = 0;
|
||||||
let realtimeHandle: unknown;
|
let realtimeHandle: unknown;
|
||||||
let realtimeAttempt = 0;
|
let realtimeAttempt = 0;
|
||||||
@@ -347,6 +357,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
|
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
|
||||||
// way its operator configured rather than the way this file happened to default.
|
// way its operator configured rather than the way this file happened to default.
|
||||||
stage.setCrossfade(snapshot.crossfadeMs);
|
stage.setCrossfade(snapshot.crossfadeMs);
|
||||||
|
// A deliberate blackout is persisted state like any other. Restoring it first means a kiosk that
|
||||||
|
// reloads mid-blackout stays black while it reconnects, instead of flashing the hidden frame.
|
||||||
|
stage.setBlackout(snapshot.playback.blackout);
|
||||||
const atMs = serverNowFrom(snapshot, now());
|
const atMs = serverNowFrom(snapshot, now());
|
||||||
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
||||||
const resolution = evaluation.resolution;
|
const resolution = evaluation.resolution;
|
||||||
@@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
if (stopped || !session) {
|
if (stopped || !session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const viewerId = session.viewerId;
|
||||||
try {
|
try {
|
||||||
const response = await options.context.api.request(
|
// Asked by viewer, not by channel: the server resolves where this screen is assigned *now*,
|
||||||
`channels/${session.channelId}/display-state`,
|
// so a reassignment or a deleted channel reaches a display that is already running.
|
||||||
);
|
const response = await options.context.api.request(`viewers/${viewerId}/display-state`);
|
||||||
|
if (await removedFromRoster(response)) {
|
||||||
|
// This screen was removed from the roster. It registers again with the identity it keeps,
|
||||||
|
// and keeps its frame meanwhile.
|
||||||
|
reregister(viewerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await refreshFailed();
|
await refreshFailed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const state = (await response.json()) as DisplayChannelStateView;
|
const state = (await response.json()) as DisplayChannelStateView;
|
||||||
refreshAttempt = 0;
|
refreshAttempt = 0;
|
||||||
|
rosterAttempt = 0;
|
||||||
|
adoptAssignment(state.channel);
|
||||||
await apply(state);
|
await apply(state);
|
||||||
} catch {
|
} catch {
|
||||||
await refreshFailed();
|
await refreshFailed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The server's answer about where this screen belongs wins, exactly as its playback state does. */
|
||||||
|
function adoptAssignment(channel: DisplayChannelStateView["channel"]): void {
|
||||||
|
if (!session || (session.channelId === channel.id && session.groupId === channel.groupId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session = {
|
||||||
|
...session,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelKey: channel.key,
|
||||||
|
groupId: channel.groupId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Signage's own "no such viewer" means the roster entry is gone. Any other 404 — a proxy, a
|
||||||
|
* route this server does not have — is an ordinary failure, retried with backoff like the rest;
|
||||||
|
* treating it as removal would re-register in a loop.
|
||||||
|
*/
|
||||||
|
async function removedFromRoster(response: Response): Promise<boolean> {
|
||||||
|
if (response.status !== 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = (await response
|
||||||
|
.clone()
|
||||||
|
.json()
|
||||||
|
.catch(() => null)) as { code?: unknown } | null;
|
||||||
|
return body?.code === "SIGNAGE_NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a session the server no longer recognizes and registers again, once, on backoff. */
|
||||||
|
function reregister(viewerId: string): void {
|
||||||
|
if (stopped || session?.viewerId !== viewerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endSession();
|
||||||
|
// Backed off as well: a server that keeps forgetting this screen the moment it registers must
|
||||||
|
// cost a request a minute, not a tight loop.
|
||||||
|
const delay = rosterAttempt === 0 ? 0 : displayRetryDelayMs(rosterAttempt);
|
||||||
|
rosterAttempt += 1;
|
||||||
|
if (delay === 0) {
|
||||||
|
void register();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionHandle = setTimer(() => {
|
||||||
|
sessionHandle = undefined;
|
||||||
|
void register();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases everything a registration started: realtime, heartbeats, and pending retries. */
|
||||||
|
function endSession(): void {
|
||||||
|
session = undefined;
|
||||||
|
activeSubscription = undefined;
|
||||||
|
subscription?.close();
|
||||||
|
subscription = undefined;
|
||||||
|
if (heartbeatHandle !== undefined) {
|
||||||
|
clearInterval(heartbeatHandle as never);
|
||||||
|
heartbeatHandle = undefined;
|
||||||
|
}
|
||||||
|
if (realtimeHandle !== undefined) {
|
||||||
|
clearTimer(realtimeHandle);
|
||||||
|
realtimeHandle = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The server is unreachable, or answered with an error. The frame stays and local continuation
|
* The server is unreachable, or answered with an error. The frame stays and local continuation
|
||||||
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
|
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
|
||||||
@@ -545,26 +632,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
* state — exactly as if it had worked the first time.
|
* state — exactly as if it had worked the first time.
|
||||||
*/
|
*/
|
||||||
async function register(): Promise<void> {
|
async function register(): Promise<void> {
|
||||||
if (stopped || session) {
|
if (stopped || session || registering) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
registering = true;
|
||||||
let registered: DisplaySessionView | undefined;
|
let registered: DisplaySessionView | undefined;
|
||||||
try {
|
try {
|
||||||
const response = await options.context.api.request("viewers/session", {
|
registered = await requestSession();
|
||||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
} finally {
|
||||||
headers: { "content-type": "application/json" },
|
registering = false;
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
// Read, not cast: a registration the display cannot understand is no registration, and is
|
|
||||||
// tried again like any other failure.
|
|
||||||
const parsed = parseViewerSessionResponse(await response.json());
|
|
||||||
registered = parsed.ok ? parsed.value : undefined;
|
|
||||||
}
|
}
|
||||||
} catch {
|
if (stopped || session) {
|
||||||
registered = undefined;
|
|
||||||
}
|
|
||||||
if (stopped) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!registered) {
|
if (!registered) {
|
||||||
@@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
report();
|
report();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestSession(): Promise<DisplaySessionView | undefined> {
|
||||||
|
let registered: DisplaySessionView | undefined;
|
||||||
|
try {
|
||||||
|
const response = await options.context.api.request("viewers/session", {
|
||||||
|
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Read, not cast: a registration the display cannot understand is no registration, and is
|
||||||
|
// tried again like any other failure.
|
||||||
|
const parsed = parseViewerSessionResponse(await response.json());
|
||||||
|
registered = parsed.ok ? parsed.value : undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
registered = undefined;
|
||||||
|
}
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to the display's realtime channel, and again whenever the host reports that a
|
* Subscribes to the display's realtime channel, and again whenever the host reports that a
|
||||||
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
|
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
|
||||||
@@ -600,10 +698,21 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
if (stopped || !session) {
|
if (stopped || !session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only the newest subscription may act. Closing the old one can report "closed" synchronously,
|
||||||
|
// and that report must not schedule a second resubscription on top of this one.
|
||||||
|
const token = Symbol("subscription");
|
||||||
|
activeSubscription = token;
|
||||||
subscription?.close();
|
subscription?.close();
|
||||||
subscription = options.context.realtime.subscribe("display-state", {
|
subscription = options.context.realtime.subscribe("display-state", {
|
||||||
onSnapshot: (state) => onRealtime(state),
|
onSnapshot: (state) => {
|
||||||
|
if (activeSubscription === token) {
|
||||||
|
onRealtime(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
|
if (activeSubscription !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
online = status === "connected";
|
online = status === "connected";
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
realtimeAttempt = 0;
|
realtimeAttempt = 0;
|
||||||
@@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
}
|
}
|
||||||
report();
|
report();
|
||||||
},
|
},
|
||||||
onUpdate: (state) => onRealtime(state),
|
onUpdate: (state) => {
|
||||||
|
if (activeSubscription === token) {
|
||||||
|
onRealtime(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,11 +752,29 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
reportedSelectionId: renderedSelectionId ?? null,
|
reportedSelectionId: renderedSelectionId ?? null,
|
||||||
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
|
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
|
||||||
};
|
};
|
||||||
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
const viewerId = session.viewerId;
|
||||||
|
const response = await options.context.api.request(`viewers/${viewerId}/heartbeat`, {
|
||||||
body: JSON.stringify(report),
|
body: JSON.stringify(report),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
if (await removedFromRoster(response)) {
|
||||||
|
reregister(viewerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The heartbeat is also how a screen learns it was reassigned while its slot is long, paused,
|
||||||
|
// or held — times when no state fetch is otherwise due.
|
||||||
|
const assigned = assignedChannelOf(await response.json().catch(() => undefined));
|
||||||
|
if (
|
||||||
|
assigned !== undefined &&
|
||||||
|
session?.viewerId === viewerId &&
|
||||||
|
assigned !== session.channelId
|
||||||
|
) {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Telemetry is best effort and never affects what is on screen.
|
// Telemetry is best effort and never affects what is on screen.
|
||||||
}
|
}
|
||||||
@@ -695,6 +826,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
activeSubscription = undefined;
|
||||||
subscription?.close();
|
subscription?.close();
|
||||||
subscription = undefined;
|
subscription = undefined;
|
||||||
if (heartbeatHandle !== undefined) {
|
if (heartbeatHandle !== undefined) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.3",
|
version: "0.1.4",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
describeViewerHealth,
|
describeViewerHealth,
|
||||||
openViewerSession,
|
openViewerSession,
|
||||||
recordViewerHeartbeat,
|
recordViewerHeartbeat,
|
||||||
|
resolveViewerSession,
|
||||||
SIGNAGE_VIEWER_NAME_MAX,
|
SIGNAGE_VIEWER_NAME_MAX,
|
||||||
updateViewerAssignment,
|
updateViewerAssignment,
|
||||||
} from "../viewers/service.js";
|
} from "../viewers/service.js";
|
||||||
@@ -123,12 +124,50 @@ export function registerViewerRoutes(
|
|||||||
});
|
});
|
||||||
// The response says what a display needs to keep itself honest, and nothing about other
|
// The response says what a display needs to keep itself honest, and nothing about other
|
||||||
// displays or the installation.
|
// displays or the installation.
|
||||||
const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written };
|
const assigned = await resolveViewerSession(store(context), result.viewer);
|
||||||
|
const response: DisplayHeartbeatResponse = {
|
||||||
|
acknowledged: true,
|
||||||
|
channelId: assigned.channel.id,
|
||||||
|
recorded: result.written,
|
||||||
|
};
|
||||||
return Response.json(response);
|
return Response.json(response);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/viewers/:viewerId/display-state",
|
||||||
|
permission: "signage.display.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const viewerId = requireUuidParam(context, "viewerId");
|
||||||
|
const signageStore = store(context);
|
||||||
|
const viewer = await signageStore.findViewer(viewerId);
|
||||||
|
if (!viewer) {
|
||||||
|
// Removed from the roster. The display registers again with the identity it keeps.
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
// Resolved on every fetch, never remembered from registration: an administrator who moves
|
||||||
|
// a screen, or deletes the channel it was on, changes what it shows without a reboot.
|
||||||
|
const { channel } = await resolveViewerSession(signageStore, viewer);
|
||||||
|
const snapshot = await playback().snapshot(channel.groupId);
|
||||||
|
return Response.json(
|
||||||
|
await buildDisplayChannelState({
|
||||||
|
atMs: Date.now(),
|
||||||
|
channel,
|
||||||
|
playback: snapshot,
|
||||||
|
registry: registry(),
|
||||||
|
settings: readSignageSettings(context.config),
|
||||||
|
storage: context.storage,
|
||||||
|
store: signageStore,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/channels/:channelId/display-state",
|
path: "/channels/:channelId/display-state",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function createSignageModuleRuntime(
|
|||||||
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
|
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
|
||||||
database: { abi: 1 },
|
database: { abi: 1 },
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.3",
|
version: "0.1.4",
|
||||||
|
|
||||||
register({ capabilities, config, http, realtime }) {
|
register({ capabilities, config, http, realtime }) {
|
||||||
const settings = readSignageSettings(config);
|
const settings = readSignageSettings(config);
|
||||||
@@ -192,7 +192,7 @@ export function createSignageModuleRuntime(
|
|||||||
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||||
return Response.json({
|
return Response.json({
|
||||||
crossfadeMs: current.crossfadeMs,
|
crossfadeMs: current.crossfadeMs,
|
||||||
moduleVersion: "0.1.3",
|
moduleVersion: "0.1.4",
|
||||||
nativeImageDurationMs: current.nativeImageDurationMs,
|
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||||
nativePresentation: current.nativePresentation,
|
nativePresentation: current.nativePresentation,
|
||||||
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise<View
|
|||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
}
|
}
|
||||||
return resolveSession(input.store, refreshed);
|
return resolveViewerSession(input.store, refreshed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = defaultChannelOf(group.channels);
|
const channel = defaultChannelOf(group.channels);
|
||||||
@@ -121,7 +121,16 @@ function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
|
/**
|
||||||
|
* Where a known viewer is assigned **now**: its own channel, or the default channel of the default
|
||||||
|
* group when that channel no longer exists. A display asks this on every state fetch, which is what
|
||||||
|
* makes an administrator's reassignment — or deleting the channel a screen was on — reach a display
|
||||||
|
* that is already running, rather than only the next time it boots.
|
||||||
|
*/
|
||||||
|
export async function resolveViewerSession(
|
||||||
|
store: SignageStore,
|
||||||
|
viewer: StoredViewer,
|
||||||
|
): Promise<ViewerSession> {
|
||||||
const groups = await store.listChannelGroups();
|
const groups = await store.listChannelGroups();
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
||||||
|
|||||||
@@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeart
|
|||||||
export interface DisplayHeartbeatResponse {
|
export interface DisplayHeartbeatResponse {
|
||||||
readonly acknowledged: true;
|
readonly acknowledged: true;
|
||||||
readonly recorded: boolean;
|
readonly recorded: boolean;
|
||||||
|
/** The channel an administrator has this viewer assigned to now. A display whose own idea of its
|
||||||
|
* channel differs refetches its state, so a reassignment reaches a running screen within one
|
||||||
|
* heartbeat. Optional: a display ignores a response without it. */
|
||||||
|
readonly channelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Channel state: GET channels/:channelId/display-state -----------------------------------------
|
/** The assigned channel from a heartbeat response, if it carried a usable one. */
|
||||||
|
export function assignedChannelOf(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== "object" || raw === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const channelId = (raw as { readonly channelId?: unknown }).channelId;
|
||||||
|
return typeof channelId === "string" && channelId.length > 0 && channelId.length <= 64
|
||||||
|
? channelId
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel state: GET viewers/:viewerId/display-state (and channels/:channelId/display-state) -----
|
||||||
|
//
|
||||||
|
// A registered display asks by its own viewer id, so the server resolves which channel it is assigned
|
||||||
|
// to at that moment. The channel route remains for the management preview and for display code a
|
||||||
|
// kiosk cached before the viewer route existed.
|
||||||
|
|
||||||
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
|
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
|
||||||
export interface DisplayMedia {
|
export interface DisplayMedia {
|
||||||
|
|||||||
+33
-36
@@ -1,9 +1,9 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
|
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 { DesktopOnly } from "../browser/desktop-gate.js";
|
||||||
import { createSignageApi } from "./api.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 { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||||
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||||
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||||
@@ -22,7 +22,20 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
|||||||
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
* 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.
|
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||||
*/
|
*/
|
||||||
export function SignageDashboard({
|
export function SignageDashboard(props: {
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
|
}): ReactElement {
|
||||||
|
// One cache for the life of this screen: everything the panels have loaded, shared between them.
|
||||||
|
const [cache] = useState(createResourceCache);
|
||||||
|
return (
|
||||||
|
<ResourceCacheContext.Provider value={cache}>
|
||||||
|
<SignageWorkspace {...props} />
|
||||||
|
</ResourceCacheContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignageWorkspace({
|
||||||
context,
|
context,
|
||||||
copyText,
|
copyText,
|
||||||
}: {
|
}: {
|
||||||
@@ -30,16 +43,15 @@ export function SignageDashboard({
|
|||||||
readonly copyText: (value: string) => Promise<void>;
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
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.
|
// 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
|
// 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.
|
// request it will certainly refuse is noise in a log rather than security.
|
||||||
const mayView = context.permissions.has("signage.view");
|
const mayView = context.permissions.has("signage.view");
|
||||||
const settings = useResource(
|
const settings = useResource(
|
||||||
() =>
|
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||||
mayView
|
|
||||||
? api.get<SettingsResponse>("settings")
|
|
||||||
: Promise.resolve(undefined),
|
|
||||||
[mayView],
|
[mayView],
|
||||||
|
mayView ? "settings" : undefined,
|
||||||
);
|
);
|
||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() =>
|
() =>
|
||||||
@@ -47,14 +59,13 @@ export function SignageDashboard({
|
|||||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||||
: Promise.resolve(undefined),
|
: Promise.resolve(undefined),
|
||||||
[mayView],
|
[mayView],
|
||||||
|
mayView ? "channel-groups" : undefined,
|
||||||
);
|
);
|
||||||
const [tab, setTab] = useState("playback");
|
const [tab, setTab] = useState("playback");
|
||||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||||
const mayManageTimelines = context.permissions.has(
|
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||||
"signage.timelines.manage",
|
|
||||||
);
|
|
||||||
const mayControl = context.permissions.has("signage.playback.control");
|
const mayControl = context.permissions.has("signage.playback.control");
|
||||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||||
|
|
||||||
@@ -68,15 +79,10 @@ export function SignageDashboard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedGroupId =
|
const selectedGroupId =
|
||||||
groupId ??
|
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||||
(groups.state.status === "ready"
|
|
||||||
? groups.state.value?.groups[0]?.id
|
|
||||||
: undefined);
|
|
||||||
const selectedGroup =
|
const selectedGroup =
|
||||||
groups.state.status === "ready"
|
groups.state.status === "ready"
|
||||||
? groups.state.value?.groups.find(
|
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
|
||||||
(candidate) => candidate.id === selectedGroupId,
|
|
||||||
)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,8 +124,7 @@ export function SignageDashboard({
|
|||||||
{...(selectedGroup
|
{...(selectedGroup
|
||||||
? {
|
? {
|
||||||
activeGroupName: selectedGroup.name,
|
activeGroupName: selectedGroup.name,
|
||||||
activeTimelineId:
|
activeTimelineId: selectedGroup.playback.activeTimelineId,
|
||||||
selectedGroup.playback.activeTimelineId,
|
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
api={api}
|
api={api}
|
||||||
@@ -127,13 +132,15 @@ export function SignageDashboard({
|
|||||||
onActivate={
|
onActivate={
|
||||||
mayControl && selectedGroupId !== undefined
|
mayControl && selectedGroupId !== undefined
|
||||||
? async (timelineId) => {
|
? async (timelineId) => {
|
||||||
await api.send(
|
await api.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||||
"POST",
|
|
||||||
`playback/${selectedGroupId}/activate`,
|
|
||||||
{
|
|
||||||
timelineId,
|
timelineId,
|
||||||
when: "now",
|
when: "now",
|
||||||
},
|
});
|
||||||
|
// Playback opens on what is now true, not on the
|
||||||
|
// timeline it was showing before.
|
||||||
|
cache?.write(
|
||||||
|
`playback/${selectedGroupId}`,
|
||||||
|
await api.get(`playback/${selectedGroupId}`),
|
||||||
);
|
);
|
||||||
groups.reload();
|
groups.reload();
|
||||||
setTab("playback");
|
setTab("playback");
|
||||||
@@ -148,11 +155,7 @@ export function SignageDashboard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: (
|
content: (
|
||||||
<ImagesPanel
|
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||||
api={api}
|
|
||||||
canManage={mayManageAssets}
|
|
||||||
settings={loaded}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
id: "images",
|
id: "images",
|
||||||
label: "Images",
|
label: "Images",
|
||||||
@@ -174,13 +177,7 @@ export function SignageDashboard({
|
|||||||
label: "Displays",
|
label: "Displays",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: (
|
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||||
<ProvidersPanel
|
|
||||||
api={api}
|
|
||||||
context={context}
|
|
||||||
settings={loaded}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
id: "providers",
|
id: "providers",
|
||||||
label: "Providers",
|
label: "Providers",
|
||||||
},
|
},
|
||||||
|
|||||||
+71
-4
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,15 +21,76 @@ export interface Resource<Value> {
|
|||||||
reload(): void;
|
reload(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last value each Signage resource loaded, shared by every panel on the screen.
|
||||||
|
*
|
||||||
|
* The tabs mount only the selected panel, so without this every tab switch threw its data away,
|
||||||
|
* flashed a spinner, and collapsed the page to its height before growing again — the "jumping" an
|
||||||
|
* operator saw. It also let two panels disagree: activating a timeline from Playback left Timelines
|
||||||
|
* showing the old one as inactive until a reload. Now a remounted panel starts from what was last
|
||||||
|
* known and refreshes quietly, and a reload anywhere updates every panel reading the same resource.
|
||||||
|
*/
|
||||||
|
export interface ResourceCache {
|
||||||
|
read(key: string): { readonly value: unknown } | undefined;
|
||||||
|
write(key: string, value: unknown): void;
|
||||||
|
subscribe(key: string, listener: (value: unknown) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResourceCache(): ResourceCache {
|
||||||
|
const values = new Map<string, { readonly value: unknown }>();
|
||||||
|
const listeners = new Map<string, Set<(value: unknown) => void>>();
|
||||||
|
return {
|
||||||
|
read: (key) => values.get(key),
|
||||||
|
subscribe(key, listener) {
|
||||||
|
const set = listeners.get(key) ?? new Set();
|
||||||
|
set.add(listener);
|
||||||
|
listeners.set(key, set);
|
||||||
|
return () => {
|
||||||
|
set.delete(listener);
|
||||||
|
if (set.size === 0) {
|
||||||
|
listeners.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
write(key, value) {
|
||||||
|
values.set(key, { value });
|
||||||
|
for (const listener of [...(listeners.get(key) ?? [])]) {
|
||||||
|
listener(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceCacheContext = createContext<ResourceCache | undefined>(undefined);
|
||||||
|
|
||||||
export function useResource<Value>(
|
export function useResource<Value>(
|
||||||
load: () => Promise<Value>,
|
load: () => Promise<Value>,
|
||||||
dependencies: readonly unknown[],
|
dependencies: readonly unknown[],
|
||||||
|
/** Shares this resource with every other panel that names the same key. */
|
||||||
|
key?: string,
|
||||||
): Resource<Value> {
|
): Resource<Value> {
|
||||||
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
|
const cache = useContext(ResourceCacheContext);
|
||||||
|
const cached = key === undefined ? undefined : cache?.read(key);
|
||||||
|
const [state, setState] = useState<Loadable<Value>>(
|
||||||
|
cached === undefined
|
||||||
|
? { status: "loading" }
|
||||||
|
: { status: "ready", value: cached.value as Value },
|
||||||
|
);
|
||||||
const generation = useRef(0);
|
const generation = useRef(0);
|
||||||
const loadRef = useRef(load);
|
const loadRef = useRef(load);
|
||||||
loadRef.current = load;
|
loadRef.current = load;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (key === undefined || !cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = cache.read(key);
|
||||||
|
if (current !== undefined) {
|
||||||
|
setState({ status: "ready", value: current.value as Value });
|
||||||
|
}
|
||||||
|
return cache.subscribe(key, (value) => setState({ status: "ready", value: value as Value }));
|
||||||
|
}, [cache, key]);
|
||||||
|
|
||||||
const run = useCallback(() => {
|
const run = useCallback(() => {
|
||||||
generation.current += 1;
|
generation.current += 1;
|
||||||
const token = generation.current;
|
const token = generation.current;
|
||||||
@@ -38,7 +99,13 @@ export function useResource<Value>(
|
|||||||
.then((value) => {
|
.then((value) => {
|
||||||
// A response from a request the panel has already moved on from must never overwrite a
|
// A response from a request the panel has already moved on from must never overwrite a
|
||||||
// newer one.
|
// newer one.
|
||||||
if (token === generation.current) {
|
if (token !== generation.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key !== undefined && cache) {
|
||||||
|
// Every panel subscribed to this key, including this one, is updated by the write.
|
||||||
|
cache.write(key, value);
|
||||||
|
} else {
|
||||||
setState({ status: "ready", value });
|
setState({ status: "ready", value });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,7 +125,7 @@ export function useResource<Value>(
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, [cache, key]);
|
||||||
|
|
||||||
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
||||||
// change, which is the whole reason this takes one.
|
// change, which is the whole reason this takes one.
|
||||||
|
|||||||
+2
-4
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.3",
|
version: "0.1.4",
|
||||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
@@ -24,9 +24,7 @@ export default defineModuleUi({
|
|||||||
context={context}
|
context={context}
|
||||||
copyText={async (value) => {
|
copyText={async (value) => {
|
||||||
if (globalThis.navigator.clipboard === undefined) {
|
if (globalThis.navigator.clipboard === undefined) {
|
||||||
throw new Error(
|
throw new Error("Clipboard access is not available in this browser.");
|
||||||
"Clipboard access is not available in this browser.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await globalThis.navigator.clipboard.writeText(value);
|
await globalThis.navigator.clipboard.writeText(value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
+139
-172
@@ -1,31 +1,42 @@
|
|||||||
import {
|
import { Badge, Button, Field, InlineNotice, Panel, Select, TextInput } from "@ekklesia/ui";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Field,
|
|
||||||
InlineNotice,
|
|
||||||
Panel,
|
|
||||||
Select,
|
|
||||||
TextInput,
|
|
||||||
} from "@ekklesia/ui";
|
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource, useTick } from "../hooks.js";
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||||
import {
|
import { ConfirmButton, FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||||
FailureNote,
|
|
||||||
formatSince,
|
|
||||||
OnlineBadge,
|
|
||||||
renderLoadable,
|
|
||||||
} from "./shared.js";
|
|
||||||
|
|
||||||
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
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({
|
function DisplayRow({
|
||||||
canManage,
|
canManage,
|
||||||
groups,
|
groups,
|
||||||
nowMs,
|
nowMs,
|
||||||
onChannelChange,
|
onChannelChange,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
onRemove,
|
||||||
viewer,
|
viewer,
|
||||||
}: {
|
}: {
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
@@ -33,31 +44,32 @@ function DisplayRow({
|
|||||||
readonly nowMs: number;
|
readonly nowMs: number;
|
||||||
readonly onChannelChange: (channelId: string) => void;
|
readonly onChannelChange: (channelId: string) => void;
|
||||||
readonly onNameChange: (name: string) => void;
|
readonly onNameChange: (name: string) => void;
|
||||||
|
readonly onRemove: () => void;
|
||||||
readonly viewer: ViewerHealthView;
|
readonly viewer: ViewerHealthView;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="signage-display-row" data-testid={`signage-display-${viewer.id}`}>
|
||||||
className="signage-display-row"
|
|
||||||
data-testid={`signage-display-${viewer.id}`}
|
|
||||||
>
|
|
||||||
<div className="signage-display-row__identity">
|
<div className="signage-display-row__identity">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<TextInput
|
<TextInput
|
||||||
aria-label={`Name for ${viewer.name}`}
|
aria-label={`Name for ${viewer.name}`}
|
||||||
defaultValue={viewer.name}
|
defaultValue={viewer.name}
|
||||||
onBlur={(event) =>
|
onBlur={(event) =>
|
||||||
event.target.value !== viewer.name &&
|
event.target.value !== viewer.name && onNameChange(event.target.value)
|
||||||
onNameChange(event.target.value)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<strong>{viewer.name}</strong>
|
<strong>{viewer.name}</strong>
|
||||||
)}
|
)}
|
||||||
<OnlineBadge online={viewer.online} />
|
<OnlineBadge online={viewer.online} />
|
||||||
|
{viewer.deviceId === null ? (
|
||||||
|
// Opened by someone signed in — a test tab on a laptop, not a paired screen.
|
||||||
|
<Badge tone="neutral">Signed-in browser</Badge>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
<div className="signage-display-row__assignment">
|
<div className="signage-display-row__assignment">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Field label="Audience channel">
|
<Field label="Audience">
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<Select
|
<Select
|
||||||
{...control}
|
{...control}
|
||||||
@@ -82,18 +94,22 @@ function DisplayRow({
|
|||||||
<div className="signage-display-row__health">
|
<div className="signage-display-row__health">
|
||||||
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
|
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
|
||||||
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||||
{viewer.offlineReady
|
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"}
|
||||||
? `${viewer.cachedAssetCount} cached`
|
|
||||||
: "Not offline ready"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{viewer.lastError === null ? undefined : (
|
{viewer.lastError === null ? undefined : (
|
||||||
<span
|
<span className="signage-display-row__error" title={viewer.lastError.detail ?? undefined}>
|
||||||
className="signage-display-row__error"
|
|
||||||
title={viewer.lastError.detail ?? undefined}
|
|
||||||
>
|
|
||||||
{viewer.lastError.code}
|
{viewer.lastError.code}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{canManage && !viewer.online ? (
|
||||||
|
// Only an offline entry: a screen that is still connected would simply reappear.
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel="Remove from the list"
|
||||||
|
label="Remove"
|
||||||
|
onConfirm={onRemove}
|
||||||
|
testId={`signage-display-remove-${viewer.id}`}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -119,10 +135,12 @@ export function DisplaysPanel({
|
|||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
[],
|
[],
|
||||||
|
"channel-groups",
|
||||||
);
|
);
|
||||||
const viewers = useResource(
|
const viewers = useResource(
|
||||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||||
[],
|
[],
|
||||||
|
"viewers",
|
||||||
);
|
);
|
||||||
const edit = useAction(() => {
|
const edit = useAction(() => {
|
||||||
groups.reload();
|
groups.reload();
|
||||||
@@ -131,12 +149,8 @@ export function DisplaysPanel({
|
|||||||
});
|
});
|
||||||
const viewerEdit = useAction(viewers.reload);
|
const viewerEdit = useAction(viewers.reload);
|
||||||
const [groupName, setGroupName] = useState("");
|
const [groupName, setGroupName] = useState("");
|
||||||
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
|
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle");
|
||||||
"idle",
|
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||||
);
|
|
||||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const [channelDraft, setChannelDraft] = useState<{
|
const [channelDraft, setChannelDraft] = useState<{
|
||||||
groupId: string;
|
groupId: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -148,15 +162,14 @@ export function DisplaysPanel({
|
|||||||
});
|
});
|
||||||
useTick(5000);
|
useTick(5000);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const readyViewers =
|
const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : [];
|
||||||
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
|
|
||||||
|
|
||||||
const updateViewer = (
|
const updateViewer = (viewerId: string, changes: Record<string, string>): void => {
|
||||||
viewerId: string,
|
|
||||||
changes: Record<string, string>,
|
|
||||||
): void => {
|
|
||||||
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
||||||
};
|
};
|
||||||
|
const removeViewer = (viewerId: string): void => {
|
||||||
|
viewerEdit.run(() => api.send("DELETE", `viewers/${viewerId}`));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signage-columns">
|
<div className="signage-columns">
|
||||||
@@ -164,14 +177,6 @@ export function DisplaysPanel({
|
|||||||
actions={
|
actions={
|
||||||
displaySetupUrl === undefined ? undefined : (
|
displaySetupUrl === undefined ? undefined : (
|
||||||
<div className="signage-pairing-actions">
|
<div className="signage-pairing-actions">
|
||||||
<a
|
|
||||||
className="ek-button ek-focusable ek-button--primary"
|
|
||||||
href={displaySetupUrl}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Open display setup
|
|
||||||
</a>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCopyStatus("idle");
|
setCopyStatus("idle");
|
||||||
@@ -180,35 +185,39 @@ export function DisplaysPanel({
|
|||||||
() => setCopyStatus("failed"),
|
() => setCopyStatus("failed"),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
variant="primary"
|
||||||
>
|
>
|
||||||
Copy URL
|
Copy setup address
|
||||||
</Button>
|
</Button>
|
||||||
|
<a
|
||||||
|
className="ek-button ek-focusable ek-button--secondary"
|
||||||
|
href={displaySetupUrl}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Preview here
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
description="Connect a screen in three short steps."
|
description="Connect a screen in three short steps."
|
||||||
title="Pair a display"
|
title="Pair a display"
|
||||||
>
|
>
|
||||||
<ol
|
<ol className="signage-pairing-steps" aria-label="Display pairing steps">
|
||||||
className="signage-pairing-steps"
|
|
||||||
aria-label="Display pairing steps"
|
|
||||||
>
|
|
||||||
<li>
|
<li>
|
||||||
<span className="signage-pairing-steps__number">1</span>
|
<span className="signage-pairing-steps__number">1</span>
|
||||||
<span>
|
<span>
|
||||||
Open display setup on the screen that will show signage.
|
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.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="signage-pairing-steps__number">2</span>
|
<span className="signage-pairing-steps__number">2</span>
|
||||||
<span>
|
<span>Enter its six-digit code under Display devices in the Dashboard sidebar.</span>
|
||||||
Enter its six-digit code under Display devices in the Dashboard
|
|
||||||
sidebar.
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="signage-pairing-steps__number">3</span>
|
<span className="signage-pairing-steps__number">3</span>
|
||||||
<span>Name the display and choose its audience channel below.</span>
|
<span>Name the display and choose its group below.</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
{displaySetupUrl === undefined ? (
|
{displaySetupUrl === undefined ? (
|
||||||
@@ -218,18 +227,15 @@ export function DisplaysPanel({
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
<span className="signage-copy-status" aria-live="polite">
|
<span className="signage-copy-status" aria-live="polite">
|
||||||
{copyStatus === "copied"
|
{copyStatus === "copied"
|
||||||
? "Display setup URL copied."
|
? "Display setup address copied."
|
||||||
: copyStatus === "failed"
|
: copyStatus === "failed"
|
||||||
? "Could not copy the URL. Copy it from the newly opened setup page."
|
? "Could not copy the address. Use Preview here and copy it from the address bar."
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
{viewers.state.status === "ready" &&
|
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
|
||||||
viewers.state.value.viewers.length === 0 ? (
|
|
||||||
<div className="signage-display-empty">
|
<div className="signage-display-empty">
|
||||||
<strong>No displays paired yet</strong>
|
<strong>No displays paired yet</strong>
|
||||||
<span>
|
<span>A newly approved screen appears below in its assigned channel.</span>
|
||||||
A newly approved screen appears below in its assigned channel.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -265,21 +271,17 @@ export function DisplaysPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
|
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"
|
title="Channel groups and displays"
|
||||||
>
|
>
|
||||||
{renderLoadable(
|
{renderLoadable(
|
||||||
groups.state,
|
groups.state,
|
||||||
(value) => {
|
(value) => {
|
||||||
const knownChannelIds = new Set(
|
const knownChannelIds = new Set(
|
||||||
value.groups.flatMap((group) =>
|
value.groups.flatMap((group) => group.channels.map((channel) => channel.id)),
|
||||||
group.channels.map((channel) => channel.id),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const unassigned = readyViewers.filter(
|
const unassigned = readyViewers.filter(
|
||||||
(viewer) =>
|
(viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId),
|
||||||
viewer.channelId === null ||
|
|
||||||
!knownChannelIds.has(viewer.channelId),
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="signage-groups" data-testid="signage-groups">
|
<div className="signage-groups" data-testid="signage-groups">
|
||||||
@@ -287,9 +289,7 @@ export function DisplaysPanel({
|
|||||||
<article className="signage-group" key={group.id}>
|
<article className="signage-group" key={group.id}>
|
||||||
<header className="signage-group__header">
|
<header className="signage-group__header">
|
||||||
<div className="signage-group__identity">
|
<div className="signage-group__identity">
|
||||||
<span className="signage-hierarchy-label">
|
<span className="signage-hierarchy-label">Channel group</span>
|
||||||
Channel group
|
|
||||||
</span>
|
|
||||||
<h3>{group.name}</h3>
|
<h3>{group.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="info">{group.playback.transport}</Badge>
|
<Badge tone="info">{group.playback.transport}</Badge>
|
||||||
@@ -308,9 +308,8 @@ export function DisplaysPanel({
|
|||||||
{confirmGroupId === group.id ? (
|
{confirmGroupId === group.id ? (
|
||||||
<div className="signage-confirm" role="alert">
|
<div className="signage-confirm" role="alert">
|
||||||
<p>
|
<p>
|
||||||
Delete {group.name}? Its playback history will be
|
Delete {group.name}? Its playback history will be removed. Displays
|
||||||
removed. Displays assigned here will move to the
|
assigned here will move to the default channel of another group.
|
||||||
default channel of another group.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
@@ -322,10 +321,7 @@ export function DisplaysPanel({
|
|||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(async () => {
|
edit.run(async () => {
|
||||||
await api.send(
|
await api.send("DELETE", `channel-groups/${group.id}`);
|
||||||
"DELETE",
|
|
||||||
`channel-groups/${group.id}`,
|
|
||||||
);
|
|
||||||
setConfirmGroupId(undefined);
|
setConfirmGroupId(undefined);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -339,13 +335,14 @@ export function DisplaysPanel({
|
|||||||
<section className="signage-audiences">
|
<section className="signage-audiences">
|
||||||
<div className="signage-audiences__heading">
|
<div className="signage-audiences__heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="signage-hierarchy-label">
|
<span className="signage-hierarchy-label">Audiences</span>
|
||||||
Audience channels
|
<h4>Who is watching these displays</h4>
|
||||||
</span>
|
|
||||||
<h4>Who this group renders for</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
Displays inherit the channel they are assigned to.
|
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.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="signage-channel-list">
|
<div className="signage-channel-list">
|
||||||
@@ -354,51 +351,39 @@ export function DisplaysPanel({
|
|||||||
(viewer) => viewer.channelId === channel.id,
|
(viewer) => viewer.channelId === channel.id,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<article
|
<article className="signage-channel" key={channel.id}>
|
||||||
className="signage-channel"
|
|
||||||
key={channel.id}
|
|
||||||
>
|
|
||||||
<header className="signage-channel__header">
|
<header className="signage-channel__header">
|
||||||
<div>
|
<div>
|
||||||
<h5>{channel.name}</h5>
|
<h5>{channel.name}</h5>
|
||||||
<div className="signage-channel__meta">
|
<div className="signage-channel__meta">
|
||||||
{channel.isDefault ? (
|
{channel.isDefault ? (
|
||||||
<Badge tone="success">
|
<Badge tone="success">Default audience</Badge>
|
||||||
Default channel
|
|
||||||
</Badge>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<span>Audience key</span>
|
|
||||||
<code>{channel.key}</code>
|
|
||||||
<Badge tone="neutral">
|
<Badge tone="neutral">
|
||||||
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
|
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage && !channel.isDefault ? (
|
{canManage && !channel.isDefault ? (
|
||||||
<Button
|
<ConfirmButton
|
||||||
disabled={edit.pending}
|
confirmLabel={
|
||||||
onClick={() =>
|
assigned.length === 0
|
||||||
edit.run(() =>
|
? `Remove ${channel.name}`
|
||||||
api.send(
|
: `Remove ${channel.name} and move its displays to the default audience`
|
||||||
"DELETE",
|
|
||||||
`channels/${channel.id}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="danger"
|
disabled={edit.pending}
|
||||||
>
|
label="Remove audience"
|
||||||
Remove channel
|
onConfirm={() =>
|
||||||
</Button>
|
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
||||||
|
}
|
||||||
|
testId={`signage-channel-remove-${channel.id}`}
|
||||||
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</header>
|
</header>
|
||||||
<div className="signage-channel__displays">
|
<div className="signage-channel__displays">
|
||||||
<span className="signage-hierarchy-label">
|
<span className="signage-hierarchy-label">Displays</span>
|
||||||
Displays
|
|
||||||
</span>
|
|
||||||
{assigned.length === 0 ? (
|
{assigned.length === 0 ? (
|
||||||
<p className="signage-channel__empty">
|
<p className="signage-channel__empty">No displays assigned.</p>
|
||||||
No displays assigned.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
assigned.map((viewer) => (
|
assigned.map((viewer) => (
|
||||||
<DisplayRow
|
<DisplayRow
|
||||||
@@ -409,9 +394,8 @@ export function DisplaysPanel({
|
|||||||
onChannelChange={(channelId) =>
|
onChannelChange={(channelId) =>
|
||||||
updateViewer(viewer.id, { channelId })
|
updateViewer(viewer.id, { channelId })
|
||||||
}
|
}
|
||||||
onNameChange={(name) =>
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
||||||
updateViewer(viewer.id, { name })
|
onRemove={() => removeViewer(viewer.id)}
|
||||||
}
|
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -423,19 +407,14 @@ export function DisplaysPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div
|
<div className="signage-channel-form" data-testid="signage-add-channel">
|
||||||
className="signage-channel-form"
|
|
||||||
data-testid="signage-add-channel"
|
|
||||||
>
|
|
||||||
<div className="signage-channel-form__heading">
|
<div className="signage-channel-form__heading">
|
||||||
<h5>Add an audience channel</h5>
|
<h5>Add an audience</h5>
|
||||||
<span>
|
<span>The default audience always remains.</span>
|
||||||
The default channel always remains available.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
hint="Shown to administrators and when assigning displays."
|
hint="Shown when you assign displays, for example Online."
|
||||||
label="Channel name"
|
label="Audience name"
|
||||||
>
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -444,25 +423,23 @@ export function DisplaysPanel({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setChannelDraft((current) => ({
|
setChannelDraft((current) => ({
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
key:
|
key: current.groupId === group.id ? current.key : "",
|
||||||
current.groupId === group.id
|
|
||||||
? current.key
|
|
||||||
: "",
|
|
||||||
name: event.target.value,
|
name: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="Online audience"
|
placeholder="Online"
|
||||||
value={
|
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||||
channelDraft.groupId === group.id
|
|
||||||
? channelDraft.name
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<details className="signage-advanced">
|
||||||
|
<summary>Advanced</summary>
|
||||||
<Field
|
<Field
|
||||||
hint="Advanced: lowercase ID sent to content providers, e.g. online."
|
hint={`Only needed when a content module expects a particular key. Leave empty to use ${audienceKeyFor(
|
||||||
label="Audience key"
|
channelDraft.groupId === group.id ? channelDraft.name : "",
|
||||||
|
group.channels.map((channel) => channel.key),
|
||||||
|
)}.`}
|
||||||
|
label="Content key"
|
||||||
>
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -472,40 +449,35 @@ export function DisplaysPanel({
|
|||||||
setChannelDraft((current) => ({
|
setChannelDraft((current) => ({
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
key: event.target.value,
|
key: event.target.value,
|
||||||
name:
|
name: current.groupId === group.id ? current.name : "",
|
||||||
current.groupId === group.id
|
|
||||||
? current.name
|
|
||||||
: "",
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
|
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
|
||||||
placeholder="online"
|
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||||
value={
|
|
||||||
channelDraft.groupId === group.id
|
|
||||||
? channelDraft.key
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
</details>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
edit.pending ||
|
edit.pending ||
|
||||||
channelDraft.groupId !== group.id ||
|
channelDraft.groupId !== group.id ||
|
||||||
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
channelDraft.name.trim() === "" ||
|
||||||
channelDraft.key.trim() === "default" ||
|
(channelDraft.key.trim() !== "" &&
|
||||||
channelDraft.name.trim() === ""
|
(!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
||||||
|
channelDraft.key.trim() === "default"))
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(async () => {
|
edit.run(async () => {
|
||||||
await api.send(
|
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
||||||
"POST",
|
key:
|
||||||
`channel-groups/${group.id}/channels`,
|
channelDraft.key.trim() ||
|
||||||
{
|
audienceKeyFor(
|
||||||
key: channelDraft.key.trim(),
|
channelDraft.name,
|
||||||
|
group.channels.map((channel) => channel.key),
|
||||||
|
),
|
||||||
name: channelDraft.name.trim(),
|
name: channelDraft.name.trim(),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
setChannelDraft({
|
setChannelDraft({
|
||||||
groupId: "",
|
groupId: "",
|
||||||
key: "",
|
key: "",
|
||||||
@@ -515,7 +487,7 @@ export function DisplaysPanel({
|
|||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
Add channel
|
Add audience
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -525,21 +497,16 @@ export function DisplaysPanel({
|
|||||||
|
|
||||||
{unassigned.length === 0 ? undefined : (
|
{unassigned.length === 0 ? undefined : (
|
||||||
<section className="signage-unassigned">
|
<section className="signage-unassigned">
|
||||||
<span className="signage-hierarchy-label">
|
<span className="signage-hierarchy-label">Needs assignment</span>
|
||||||
Needs assignment
|
|
||||||
</span>
|
|
||||||
{unassigned.map((viewer) => (
|
{unassigned.map((viewer) => (
|
||||||
<DisplayRow
|
<DisplayRow
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
groups={value.groups}
|
groups={value.groups}
|
||||||
key={viewer.id}
|
key={viewer.id}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
onChannelChange={(channelId) =>
|
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
|
||||||
updateViewer(viewer.id, { channelId })
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
||||||
}
|
onRemove={() => removeViewer(viewer.id)}
|
||||||
onNameChange={(name) =>
|
|
||||||
updateViewer(viewer.id, { name })
|
|
||||||
}
|
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource } from "../hooks.js";
|
import { useAction, useResource } from "../hooks.js";
|
||||||
import type { ImageRecord, SettingsResponse } from "../types.js";
|
import type { ImageRecord, SettingsResponse } from "../types.js";
|
||||||
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
|
import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The native image library and its upload queue.
|
* The native image library and its upload queue.
|
||||||
@@ -28,7 +28,11 @@ export function ImagesPanel({
|
|||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
const images = useResource(
|
||||||
|
() => api.get<{ images: readonly ImageRecord[] }>("images"),
|
||||||
|
[],
|
||||||
|
"images",
|
||||||
|
);
|
||||||
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
||||||
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
||||||
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
||||||
@@ -204,13 +208,15 @@ export function ImagesPanel({
|
|||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<ConfirmButton
|
||||||
|
confirmLabel="Delete this image"
|
||||||
disabled={remove.pending}
|
disabled={remove.pending}
|
||||||
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
|
label="Delete"
|
||||||
variant="danger"
|
onConfirm={() =>
|
||||||
>
|
remove.run(() => api.send("DELETE", `images/${image.id}`))
|
||||||
Delete
|
}
|
||||||
</Button>
|
testId={`signage-image-delete-${image.id}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import {
|
import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
||||||
ActionBar,
|
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
EmptyState,
|
|
||||||
Field,
|
|
||||||
Panel,
|
|
||||||
Select,
|
|
||||||
} from "@ekklesia/ui";
|
|
||||||
import { type ReactElement, useState } from "react";
|
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource, useTick } from "../hooks.js";
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -18,12 +10,7 @@ import type {
|
|||||||
TimelineSummary,
|
TimelineSummary,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { PreviewPanel } from "./PreviewPanel.js";
|
import { PreviewPanel } from "./PreviewPanel.js";
|
||||||
import {
|
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||||
FailureNote,
|
|
||||||
formatDuration,
|
|
||||||
formatInstant,
|
|
||||||
renderLoadable,
|
|
||||||
} from "./shared.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live transport for one channel group.
|
* Live transport for one channel group.
|
||||||
@@ -50,10 +37,12 @@ export function PlaybackPanel({
|
|||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
[],
|
[],
|
||||||
|
"channel-groups",
|
||||||
);
|
);
|
||||||
const timelines = useResource(
|
const timelines = useResource(
|
||||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
[],
|
[],
|
||||||
|
"timelines",
|
||||||
);
|
);
|
||||||
const detail = useResource(
|
const detail = useResource(
|
||||||
() =>
|
() =>
|
||||||
@@ -61,6 +50,7 @@ export function PlaybackPanel({
|
|||||||
? Promise.resolve(undefined)
|
? Promise.resolve(undefined)
|
||||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||||
[groupId],
|
[groupId],
|
||||||
|
groupId === undefined ? undefined : `playback/${groupId}`,
|
||||||
);
|
);
|
||||||
const command = useAction(() => {
|
const command = useAction(() => {
|
||||||
detail.reload();
|
detail.reload();
|
||||||
@@ -75,6 +65,10 @@ export function PlaybackPanel({
|
|||||||
: undefined;
|
: undefined;
|
||||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||||
useTick(1000);
|
useTick(1000);
|
||||||
|
useManagementUpdates(context, groupId, () => {
|
||||||
|
detail.reload();
|
||||||
|
groups.reload();
|
||||||
|
});
|
||||||
|
|
||||||
function run(path: string, body?: unknown): void {
|
function run(path: string, body?: unknown): void {
|
||||||
if (groupId === undefined) {
|
if (groupId === undefined) {
|
||||||
@@ -88,9 +82,8 @@ export function PlaybackPanel({
|
|||||||
return timelineId;
|
return timelineId;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
timelines.state.value.timelines.find(
|
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
|
||||||
(timeline) => timeline.id === timelineId,
|
timelineId
|
||||||
)?.name ?? timelineId
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +123,7 @@ export function PlaybackPanel({
|
|||||||
value === undefined ? (
|
value === undefined ? (
|
||||||
<EmptyState title="No channel group selected" />
|
<EmptyState title="No channel group selected" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="signage-playback" data-testid="signage-playback">
|
||||||
className="signage-playback"
|
|
||||||
data-testid="signage-playback"
|
|
||||||
>
|
|
||||||
<div className="signage-playback__status">
|
<div className="signage-playback__status">
|
||||||
{value.snapshot.activeTimelineId === null ? undefined : (
|
{value.snapshot.activeTimelineId === null ? undefined : (
|
||||||
<Badge tone="success">
|
<Badge tone="success">
|
||||||
@@ -151,9 +141,7 @@ export function PlaybackPanel({
|
|||||||
>
|
>
|
||||||
{value.snapshot.transport}
|
{value.snapshot.transport}
|
||||||
</Badge>
|
</Badge>
|
||||||
{value.snapshot.blackout ? (
|
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
||||||
<Badge tone="danger">Blackout</Badge>
|
|
||||||
) : undefined}
|
|
||||||
{value.current?.stale === true ? (
|
{value.current?.stale === true ? (
|
||||||
<Badge tone="warning">Stale</Badge>
|
<Badge tone="warning">Stale</Badge>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -173,10 +161,7 @@ export function PlaybackPanel({
|
|||||||
) : (
|
) : (
|
||||||
<section className="signage-playback__now">
|
<section className="signage-playback__now">
|
||||||
<h3>Now showing</h3>
|
<h3>Now showing</h3>
|
||||||
<dl
|
<dl className="signage-playback__current" data-testid="signage-current">
|
||||||
className="signage-playback__current"
|
|
||||||
data-testid="signage-current"
|
|
||||||
>
|
|
||||||
<dt>Provider</dt>
|
<dt>Provider</dt>
|
||||||
<dd>{value.current.providerId}</dd>
|
<dd>{value.current.providerId}</dd>
|
||||||
<dt>Item</dt>
|
<dt>Item</dt>
|
||||||
@@ -192,12 +177,7 @@ export function PlaybackPanel({
|
|||||||
: formatDuration(value.snapshot.remainingMs)}
|
: formatDuration(value.snapshot.remainingMs)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Started</dt>
|
<dt>Started</dt>
|
||||||
<dd>
|
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||||
{formatInstant(
|
|
||||||
value.current.startedAtMs,
|
|
||||||
settings.timezone,
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -208,10 +188,7 @@ export function PlaybackPanel({
|
|||||||
data-testid="signage-controls"
|
data-testid="signage-controls"
|
||||||
label="Playback controls"
|
label="Playback controls"
|
||||||
>
|
>
|
||||||
<Button
|
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("previous")}
|
|
||||||
>
|
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
{value.snapshot.transport === "paused" ? (
|
{value.snapshot.transport === "paused" ? (
|
||||||
@@ -223,27 +200,17 @@ export function PlaybackPanel({
|
|||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("pause")}
|
|
||||||
>
|
|
||||||
Pause
|
Pause
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("next")}
|
|
||||||
>
|
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={command.pending}
|
disabled={command.pending}
|
||||||
onClick={() =>
|
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||||
run("blackout", { on: !value.snapshot.blackout })
|
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||||
}
|
|
||||||
variant={
|
|
||||||
value.snapshot.blackout ? "primary" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -255,10 +222,7 @@ export function PlaybackPanel({
|
|||||||
Release hold
|
Release hold
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button disabled={command.pending} onClick={() => run("reload")}>
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("reload")}
|
|
||||||
>
|
|
||||||
Reload content
|
Reload content
|
||||||
</Button>
|
</Button>
|
||||||
</ActionBar>
|
</ActionBar>
|
||||||
@@ -274,17 +238,12 @@ export function PlaybackPanel({
|
|||||||
{(control) => (
|
{(control) => (
|
||||||
<Select
|
<Select
|
||||||
{...control}
|
{...control}
|
||||||
onChange={(event) =>
|
onChange={(event) => setActivateId(event.target.value)}
|
||||||
setActivateId(event.target.value)
|
|
||||||
}
|
|
||||||
value={activateId}
|
value={activateId}
|
||||||
>
|
>
|
||||||
<option value="">Choose a timeline…</option>
|
<option value="">Choose a timeline…</option>
|
||||||
{loaded.timelines.map((timeline) => (
|
{loaded.timelines.map((timeline) => (
|
||||||
<option
|
<option key={timeline.id} value={timeline.id}>
|
||||||
key={timeline.id}
|
|
||||||
value={timeline.id}
|
|
||||||
>
|
|
||||||
{timeline.name}
|
{timeline.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -323,18 +282,11 @@ export function PlaybackPanel({
|
|||||||
|
|
||||||
{value.history.length === 0 ? undefined : (
|
{value.history.length === 0 ? undefined : (
|
||||||
<div className="signage-playback__history">
|
<div className="signage-playback__history">
|
||||||
<h4 className="signage-editor__subtitle">
|
<h4 className="signage-editor__subtitle">Recently played</h4>
|
||||||
Recently played
|
|
||||||
</h4>
|
|
||||||
<ul className="ek-list">
|
<ul className="ek-list">
|
||||||
{value.history.slice(0, 8).map((entry) => (
|
{value.history.slice(0, 8).map((entry) => (
|
||||||
<li
|
<li className="ek-list__item signage-row" key={entry.id}>
|
||||||
className="ek-list__item signage-row"
|
<span className="signage-row__name">{entry.itemId}</span>
|
||||||
key={entry.id}
|
|
||||||
>
|
|
||||||
<span className="signage-row__name">
|
|
||||||
{entry.itemId}
|
|
||||||
</span>
|
|
||||||
<span className="signage-playback__history-meta">
|
<span className="signage-playback__history-meta">
|
||||||
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -355,3 +307,46 @@ export function PlaybackPanel({
|
|||||||
</div>
|
</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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|||||||
import { EmptyState, Panel } from "@ekklesia/ui";
|
import { EmptyState, Panel } from "@ekklesia/ui";
|
||||||
import { type ReactElement, useEffect, useRef } from "react";
|
import { type ReactElement, useEffect, useRef } from "react";
|
||||||
import { SignageSlate } from "../../browser/slate.js";
|
import { SignageSlate } from "../../browser/slate.js";
|
||||||
|
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
|
||||||
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
|
|
||||||
@@ -36,7 +37,16 @@ export function PreviewPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const controller = createDisplayController({ context, root, telemetry: false });
|
// The display's own rendering code, but nothing it keeps: a management browser is not a wall,
|
||||||
|
// so it persists no snapshot and caches no image bytes, and never restores one channel's last
|
||||||
|
// frame into another channel's preview.
|
||||||
|
const controller = createDisplayController({
|
||||||
|
context,
|
||||||
|
media: createMemoryMediaCache(),
|
||||||
|
root,
|
||||||
|
store: createMemoryStore(),
|
||||||
|
telemetry: false,
|
||||||
|
});
|
||||||
let handle: ReturnType<typeof setTimeout> | undefined;
|
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
async function pump(): Promise<void> {
|
async function pump(): Promise<void> {
|
||||||
|
|||||||
@@ -42,18 +42,15 @@ export function ProvidersPanel({
|
|||||||
const providers = useResource(
|
const providers = useResource(
|
||||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
[],
|
[],
|
||||||
|
"providers",
|
||||||
);
|
);
|
||||||
const published = new Set(
|
const published = new Set(
|
||||||
context.contributions
|
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||||
.list(SLIDE_RENDERER_CAPABILITY)
|
|
||||||
.map((contribution) => contribution.id),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
actions={
|
actions={<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>}
|
||||||
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
|
|
||||||
}
|
|
||||||
description="Running content sources available to timelines."
|
description="Running content sources available to timelines."
|
||||||
title="Content providers"
|
title="Content providers"
|
||||||
>
|
>
|
||||||
@@ -78,17 +75,12 @@ export function ProvidersPanel({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{value.providers.map((provider) => {
|
{value.providers.map((provider) => {
|
||||||
const rendererPublished = published.has(
|
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||||
provider.renderer.contributionId,
|
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||||
);
|
|
||||||
const isNative =
|
|
||||||
provider.renderer.contributionId === "signage.native-image";
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={provider.providerId}>
|
<TableRow key={provider.providerId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="signage-provider__id">
|
<span className="signage-provider__id">{provider.providerId}</span>
|
||||||
{provider.providerId}
|
|
||||||
</span>
|
|
||||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{provider.contractVersion}</TableCell>
|
<TableCell>{provider.contractVersion}</TableCell>
|
||||||
@@ -96,9 +88,7 @@ export function ProvidersPanel({
|
|||||||
{isNative ? (
|
{isNative ? (
|
||||||
<Badge tone="info">Built in</Badge>
|
<Badge tone="info">Built in</Badge>
|
||||||
) : rendererPublished ? (
|
) : rendererPublished ? (
|
||||||
<Badge tone="success">
|
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||||
{provider.renderer.contributionId}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<Badge tone="warning">Renderer not published</Badge>
|
<Badge tone="warning">Renderer not published</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -113,10 +103,7 @@ export function ProvidersPanel({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatInstant(
|
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||||
provider.lastSuccessAt,
|
|
||||||
settings.timezone,
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
TimelineSummary,
|
TimelineSummary,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import {
|
import {
|
||||||
|
ConfirmButton,
|
||||||
FailureNote,
|
FailureNote,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatInstant,
|
formatInstant,
|
||||||
@@ -60,10 +61,12 @@ export function TimelinesPanel({
|
|||||||
const timelines = useResource(
|
const timelines = useResource(
|
||||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
[],
|
[],
|
||||||
|
"timelines",
|
||||||
);
|
);
|
||||||
const providers = useResource(
|
const providers = useResource(
|
||||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
[],
|
[],
|
||||||
|
"providers",
|
||||||
);
|
);
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -92,9 +95,7 @@ export function TimelinesPanel({
|
|||||||
<Button
|
<Button
|
||||||
disabled={create.pending || newName.trim().length === 0}
|
disabled={create.pending || newName.trim().length === 0}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
create.run(() =>
|
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
|
||||||
api.send("POST", "timelines", { name: newName.trim() }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
@@ -126,10 +127,7 @@ export function TimelinesPanel({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{value.timelines.map((timeline) => (
|
{value.timelines.map((timeline) => (
|
||||||
<TableRow
|
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
|
||||||
key={timeline.id}
|
|
||||||
selected={timeline.id === selectedId}
|
|
||||||
>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="signage-timeline-name">
|
<div className="signage-timeline-name">
|
||||||
<Button
|
<Button
|
||||||
@@ -141,16 +139,13 @@ export function TimelinesPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
{timeline.id === activeTimelineId ? (
|
{timeline.id === activeTimelineId ? (
|
||||||
<Badge tone="success">
|
<Badge tone="success">
|
||||||
{activeGroupName
|
{activeGroupName ? `Active in ${activeGroupName}` : "Active"}
|
||||||
? `Active in ${activeGroupName}`
|
|
||||||
: "Active"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{timeline.eligibleFrom === null &&
|
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
|
||||||
timeline.eligibleUntil === null
|
|
||||||
? "Always"
|
? "Always"
|
||||||
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -158,18 +153,11 @@ export function TimelinesPanel({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{onActivate === undefined ? undefined : (
|
{onActivate === undefined ? undefined : (
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={activation.pending || timeline.id === activeTimelineId}
|
||||||
activation.pending ||
|
onClick={() => activation.run(() => onActivate(timeline.id))}
|
||||||
timeline.id === activeTimelineId
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
activation.run(() => onActivate(timeline.id))
|
|
||||||
}
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
{timeline.id === activeTimelineId
|
{timeline.id === activeTimelineId ? "Active" : "Activate"}
|
||||||
? "Active"
|
|
||||||
: "Activate"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -191,11 +179,7 @@ export function TimelinesPanel({
|
|||||||
key={selectedId}
|
key={selectedId}
|
||||||
onChanged={timelines.reload}
|
onChanged={timelines.reload}
|
||||||
onClose={() => setSelectedId(undefined)}
|
onClose={() => setSelectedId(undefined)}
|
||||||
providers={
|
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
|
||||||
providers.state.status === "ready"
|
|
||||||
? providers.state.value.providers
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
settings={settings}
|
settings={settings}
|
||||||
timelineId={selectedId}
|
timelineId={selectedId}
|
||||||
/>
|
/>
|
||||||
@@ -224,19 +208,19 @@ function TimelineEditor({
|
|||||||
const detail = useResource(
|
const detail = useResource(
|
||||||
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
||||||
[timelineId],
|
[timelineId],
|
||||||
|
`timelines/${timelineId}`,
|
||||||
);
|
);
|
||||||
const images = useResource(
|
const images = useResource(
|
||||||
() => api.get<{ images: readonly ImageRecord[] }>("images"),
|
() => api.get<{ images: readonly ImageRecord[] }>("images"),
|
||||||
[],
|
[],
|
||||||
|
"images",
|
||||||
);
|
);
|
||||||
const edit = useAction(() => {
|
const edit = useAction(() => {
|
||||||
detail.reload();
|
detail.reload();
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
});
|
||||||
const [providerId, setProviderId] = useState("");
|
const [providerId, setProviderId] = useState("");
|
||||||
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
|
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
||||||
|
|
||||||
function loadProvider(nextProviderId: string): void {
|
function loadProvider(nextProviderId: string): void {
|
||||||
@@ -273,10 +257,7 @@ function TimelineEditor({
|
|||||||
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div
|
<div className="signage-editor__add" data-testid="signage-add-source">
|
||||||
className="signage-editor__add"
|
|
||||||
data-testid="signage-add-source"
|
|
||||||
>
|
|
||||||
<Field
|
<Field
|
||||||
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
||||||
label="Content provider"
|
label="Content provider"
|
||||||
@@ -289,10 +270,7 @@ function TimelineEditor({
|
|||||||
>
|
>
|
||||||
<option value="">Choose a provider…</option>
|
<option value="">Choose a provider…</option>
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
<option
|
<option key={provider.providerId} value={provider.providerId}>
|
||||||
key={provider.providerId}
|
|
||||||
value={provider.providerId}
|
|
||||||
>
|
|
||||||
{provider.providerId}
|
{provider.providerId}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -348,32 +326,24 @@ function TimelineEditor({
|
|||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||||
"POST",
|
|
||||||
`timelines/${timelineId}/slots`,
|
|
||||||
{
|
|
||||||
sourceId: source.id,
|
sourceId: source.id,
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Add slot
|
Add slot
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ConfirmButton
|
||||||
|
confirmLabel="Remove this source"
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
label="Remove"
|
||||||
|
onConfirm={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
|
||||||
"DELETE",
|
|
||||||
`timelines/${timelineId}/sources/${source.id}`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
variant="danger"
|
/>
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
@@ -403,8 +373,7 @@ function TimelineEditor({
|
|||||||
(candidate) => candidate.id === slot.sourceId,
|
(candidate) => candidate.id === slot.sourceId,
|
||||||
);
|
);
|
||||||
const image =
|
const image =
|
||||||
slot.providerId === NATIVE_PROVIDER_ID &&
|
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
|
||||||
images.state.status === "ready"
|
|
||||||
? images.state.value.images.find(
|
? images.state.value.images.find(
|
||||||
(candidate) => candidate.id === slot.itemId,
|
(candidate) => candidate.id === slot.itemId,
|
||||||
)
|
)
|
||||||
@@ -415,8 +384,7 @@ function TimelineEditor({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{slot.kind === "provider-source"
|
{slot.kind === "provider-source"
|
||||||
? (source?.label ?? "Removed source")
|
? (source?.label ?? "Removed source")
|
||||||
: (image?.displayName ??
|
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
|
||||||
`${slot.providerId} · ${slot.itemId}`)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{slot.durationOverrideMs === null
|
{slot.durationOverrideMs === null
|
||||||
@@ -430,13 +398,9 @@ function TimelineEditor({
|
|||||||
label={slot.enabled ? "On" : "Off"}
|
label={slot.enabled ? "On" : "Off"}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
|
||||||
"PATCH",
|
|
||||||
`timelines/${timelineId}/slots/${slot.id}`,
|
|
||||||
{
|
|
||||||
enabled: event.target.checked,
|
enabled: event.target.checked,
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -448,63 +412,44 @@ function TimelineEditor({
|
|||||||
disabled={index === 0 || edit.pending}
|
disabled={index === 0 || edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||||
"PUT",
|
|
||||||
`timelines/${timelineId}/slot-order`,
|
|
||||||
{
|
|
||||||
slotIds: swap(
|
slotIds: swap(
|
||||||
value.slots.map(
|
value.slots.map((candidate) => candidate.id),
|
||||||
(candidate) => candidate.id,
|
|
||||||
),
|
|
||||||
index,
|
index,
|
||||||
index - 1,
|
index - 1,
|
||||||
),
|
),
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Up
|
Up
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={index === value.slots.length - 1 || edit.pending}
|
||||||
index === value.slots.length - 1 ||
|
|
||||||
edit.pending
|
|
||||||
}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||||
"PUT",
|
|
||||||
`timelines/${timelineId}/slot-order`,
|
|
||||||
{
|
|
||||||
slotIds: swap(
|
slotIds: swap(
|
||||||
value.slots.map(
|
value.slots.map((candidate) => candidate.id),
|
||||||
(candidate) => candidate.id,
|
|
||||||
),
|
|
||||||
index,
|
index,
|
||||||
index + 1,
|
index + 1,
|
||||||
),
|
),
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Down
|
Down
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ConfirmButton
|
||||||
|
confirmLabel="Remove this slot"
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
label="Remove"
|
||||||
|
onConfirm={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send(
|
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
|
||||||
"DELETE",
|
|
||||||
`timelines/${timelineId}/slots/${slot.id}`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
variant="danger"
|
/>
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -529,18 +474,18 @@ function TimelineEditor({
|
|||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ConfirmButton
|
||||||
|
confirmLabel={`Delete ${value.timeline.name}`}
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
label="Delete timeline"
|
||||||
|
onConfirm={() =>
|
||||||
edit.run(async () => {
|
edit.run(async () => {
|
||||||
await api.send("DELETE", `timelines/${timelineId}`);
|
await api.send("DELETE", `timelines/${timelineId}`);
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
variant="danger"
|
testId="signage-timeline-delete"
|
||||||
>
|
/>
|
||||||
Delete timeline
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
|
import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
|
||||||
import type { SignageApiFailure } from "../api.js";
|
import type { SignageApiFailure } from "../api.js";
|
||||||
import type { Loadable } from "../hooks.js";
|
import type { Loadable } from "../hooks.js";
|
||||||
|
|
||||||
@@ -89,3 +89,80 @@ export function formatSince(atMs: number | null, nowMs: number): string {
|
|||||||
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
||||||
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long an armed destructive button waits for its second press before standing down. */
|
||||||
|
export const CONFIRM_RESET_MS = 6000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A moderate destructive action, confirmed in place (DESIGN §21.3) — never with a browser dialog.
|
||||||
|
*
|
||||||
|
* The first press arms it: the button says plainly what will happen and a Cancel appears beside it.
|
||||||
|
* A second, deliberate press performs the action. Walking away, pressing Cancel, or Escape disarms
|
||||||
|
* it, so an armed button can never be left waiting for an accidental click.
|
||||||
|
*/
|
||||||
|
export function ConfirmButton({
|
||||||
|
label,
|
||||||
|
confirmLabel,
|
||||||
|
disabled,
|
||||||
|
onConfirm,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
readonly label: string;
|
||||||
|
/** What the second press does, in words: "Delete this image", not "Are you sure?". */
|
||||||
|
readonly confirmLabel: string;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
readonly onConfirm: () => void;
|
||||||
|
readonly testId?: string;
|
||||||
|
}): ReactElement {
|
||||||
|
const [armed, setArmed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!armed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setArmed(false), CONFIRM_RESET_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [armed]);
|
||||||
|
|
||||||
|
if (!armed) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-testid={testId}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setArmed(true)}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
className="signage-confirm-inline"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setArmed(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
autoFocus
|
||||||
|
data-testid={testId === undefined ? undefined : `${testId}-confirm`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setArmed(false);
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid={testId === undefined ? undefined : `${testId}-cancel`}
|
||||||
|
onClick={() => setArmed(false)}
|
||||||
|
variant="quiet"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+22
-14
@@ -176,10 +176,7 @@
|
|||||||
.signage-image-grid {
|
.signage-image-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap);
|
gap: var(--signage-gap);
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
|
||||||
auto-fill,
|
|
||||||
minmax(var(--signage-thumbnail-width), 1fr)
|
|
||||||
);
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -507,14 +504,30 @@
|
|||||||
padding: var(--ekklesia-spacing-sm);
|
padding: var(--ekklesia-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signage-advanced > summary {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-advanced[open] > summary {
|
||||||
|
margin-block-end: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-confirm-inline {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
.signage-confirm p {
|
.signage-confirm p {
|
||||||
flex: 1 1 30rem;
|
flex: 1 1 30rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-audiences {
|
.signage-audiences {
|
||||||
border-block-start: var(--ekklesia-border-width) solid
|
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
var(--ekklesia-border-divider);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap);
|
gap: var(--signage-gap);
|
||||||
padding-block-start: var(--signage-gap);
|
padding-block-start: var(--signage-gap);
|
||||||
@@ -545,8 +558,7 @@
|
|||||||
.signage-channel {
|
.signage-channel {
|
||||||
background: var(--ekklesia-background-surface);
|
background: var(--ekklesia-background-surface);
|
||||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
border-inline-start: var(--ekklesia-spacing-2xs) solid
|
border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base);
|
||||||
var(--ekklesia-accent-base);
|
|
||||||
border-radius: var(--ekklesia-radius-md);
|
border-radius: var(--ekklesia-radius-md);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
@@ -584,8 +596,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signage-channel__displays {
|
.signage-channel__displays {
|
||||||
border-block-start: var(--ekklesia-border-width) solid
|
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
var(--ekklesia-border-divider);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
padding-block-start: var(--signage-gap-tight);
|
padding-block-start: var(--signage-gap-tight);
|
||||||
@@ -603,10 +614,7 @@
|
|||||||
border-radius: var(--ekklesia-radius-md);
|
border-radius: var(--ekklesia-radius-md);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(
|
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr);
|
||||||
14rem,
|
|
||||||
1.2fr
|
|
||||||
);
|
|
||||||
padding: var(--ekklesia-spacing-sm);
|
padding: var(--ekklesia-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ describe("display ↔ server contract", () => {
|
|||||||
|
|
||||||
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
||||||
expect(sent).toContain("POST /viewers/session");
|
expect(sent).toContain("POST /viewers/session");
|
||||||
expect(sent).toContain("GET /channels/:channelId/display-state");
|
// By viewer, so the server — not the display's memory of its registration — decides the channel.
|
||||||
|
expect(sent).toContain("GET /viewers/:viewerId/display-state");
|
||||||
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
|
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
|
||||||
// Not one refusal, of any kind, for anything the display built itself.
|
// Not one refusal, of any kind, for anything the display built itself.
|
||||||
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
|
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
|
||||||
@@ -220,4 +221,48 @@ describe("display ↔ server contract", () => {
|
|||||||
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
|
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
|
||||||
expect(viewer?.reportedContributionId).toBeNull();
|
expect(viewer?.reportedContributionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a display moved to another channel group is served that group's state on its next fetch", async () => {
|
||||||
|
const display = controller();
|
||||||
|
await display.start();
|
||||||
|
await settle();
|
||||||
|
const viewerId = display.diagnostics().viewerId;
|
||||||
|
const firstChannel = display.diagnostics().channelId;
|
||||||
|
expect(viewerId).toBeDefined();
|
||||||
|
|
||||||
|
// An administrator creates a second group and moves this screen to it while it is running.
|
||||||
|
const groupId = newId();
|
||||||
|
const channelId = newId();
|
||||||
|
await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" });
|
||||||
|
await store.updateViewer(viewerId as string, { channelId });
|
||||||
|
|
||||||
|
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||||
|
const state = (await response.json()) as { channel: { id: string; groupId: string } };
|
||||||
|
expect(state.channel).toMatchObject({ groupId, id: channelId });
|
||||||
|
expect(channelId).not.toBe(firstChannel);
|
||||||
|
|
||||||
|
// And the heartbeat names the new assignment, which is how a display with a long slot hears.
|
||||||
|
const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, {
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId);
|
||||||
|
await display.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => {
|
||||||
|
const display = controller();
|
||||||
|
await display.start();
|
||||||
|
await settle();
|
||||||
|
const viewerId = display.diagnostics().viewerId as string;
|
||||||
|
await store.updateViewer(viewerId, { channelId: null });
|
||||||
|
|
||||||
|
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const [group] = await store.listChannelGroups();
|
||||||
|
const fallback = group?.channels.find((channel) => channel.isDefault);
|
||||||
|
expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id);
|
||||||
|
await display.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ describe("a display recovers from every failed request without a reload", () =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => {
|
respond: () => {
|
||||||
server.stateRequests += 1;
|
server.stateRequests += 1;
|
||||||
if (server.stateFailing) {
|
if (server.stateFailing) {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ describe("display controller", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -205,7 +205,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -376,6 +376,28 @@ describe("display controller", () => {
|
|||||||
await controller.stop();
|
await controller.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores a blackout before the server answers, so a reload never flashes content", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
const online = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const before = build({ host: online, media, store, telemetry: false });
|
||||||
|
await before.start();
|
||||||
|
await before.apply(state({ playback: { blackout: true } as never }));
|
||||||
|
await settle();
|
||||||
|
await before.stop();
|
||||||
|
|
||||||
|
// A kiosk reloads mid-blackout and its first request has not been answered yet.
|
||||||
|
root.dataset.blackout = "false";
|
||||||
|
const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
|
||||||
|
Object.assign(pending.context.api, { request: () => new Promise<Response>(() => undefined) });
|
||||||
|
const after = build({ host: pending, media, store });
|
||||||
|
void after.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(root.dataset.blackout).toBe("true");
|
||||||
|
await after.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
|
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
|
||||||
const beats: unknown[] = [];
|
const beats: unknown[] = [];
|
||||||
const host = createFakeHost({
|
const host = createFakeHost({
|
||||||
@@ -384,7 +406,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -428,7 +450,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -471,4 +493,146 @@ describe("display controller", () => {
|
|||||||
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
await controller.stop();
|
await controller.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows the server when an administrator moves it to another channel", async () => {
|
||||||
|
const moved = { groupId: "group-2", id: "chan-2", key: "online" };
|
||||||
|
let stateFetches = 0;
|
||||||
|
let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined;
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: /^viewers\/[^/]+\/display-state$/u,
|
||||||
|
respond: () => {
|
||||||
|
stateFetches += 1;
|
||||||
|
// Registered on chan-1; moved by an administrator right after its first fetch.
|
||||||
|
return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: /^viewers\/.*\/heartbeat$/u,
|
||||||
|
respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Object.assign(host.context.realtime, {
|
||||||
|
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
|
||||||
|
realtimeHandlers = handlers;
|
||||||
|
return { close: () => undefined };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The heartbeat named a different channel, so the display refetched and adopted it.
|
||||||
|
expect(stateFetches).toBe(2);
|
||||||
|
expect(controller.diagnostics().channelId).toBe("chan-2");
|
||||||
|
|
||||||
|
// A change in the old group no longer concerns it; a change in the new one does.
|
||||||
|
realtimeHandlers?.onUpdate?.({
|
||||||
|
groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }],
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(stateFetches).toBe(2);
|
||||||
|
realtimeHandlers?.onUpdate?.({
|
||||||
|
groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }],
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(stateFetches).toBe(3);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers again, exactly once, when it was removed from the roster", async () => {
|
||||||
|
let sessions = 0;
|
||||||
|
let subscriptions = 0;
|
||||||
|
let closes = 0;
|
||||||
|
let removed = false;
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "viewers/session",
|
||||||
|
respond: () => {
|
||||||
|
sessions += 1;
|
||||||
|
removed = false;
|
||||||
|
return jsonResponse(SESSION);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: /^viewers\/[^/]+\/display-state$/u,
|
||||||
|
respond: () =>
|
||||||
|
removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: /^viewers\/.*\/heartbeat$/u,
|
||||||
|
respond: () =>
|
||||||
|
removed
|
||||||
|
? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404)
|
||||||
|
: jsonResponse({ acknowledged: true }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let onUpdate: ((state: unknown) => void) | undefined;
|
||||||
|
Object.assign(host.context.realtime, {
|
||||||
|
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
|
||||||
|
subscriptions += 1;
|
||||||
|
onUpdate = handlers.onUpdate;
|
||||||
|
return { close: () => (closes += 1) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
expect(sessions).toBe(1);
|
||||||
|
|
||||||
|
// Removed from the roster; a realtime nudge and a heartbeat both discover it at once.
|
||||||
|
removed = true;
|
||||||
|
onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] });
|
||||||
|
onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] });
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(sessions).toBe(2);
|
||||||
|
// One live subscription: the old one closed, exactly one new one opened.
|
||||||
|
expect(subscriptions - closes).toBe(1);
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => {
|
||||||
|
let sessions = 0;
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "viewers/session",
|
||||||
|
respond: () => {
|
||||||
|
sessions += 1;
|
||||||
|
return jsonResponse(SESSION);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: /^viewers\/[^/]+\/display-state$/u,
|
||||||
|
// A proxy, or a server without this route — not "this viewer is gone".
|
||||||
|
respond: () => jsonResponse({ code: "NOT_FOUND" }, 404),
|
||||||
|
},
|
||||||
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(sessions).toBe(1);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
||||||
import {
|
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
|
||||||
createFakeHost,
|
|
||||||
errorResponse,
|
|
||||||
FAKE_SETTINGS,
|
|
||||||
jsonResponse,
|
|
||||||
} from "./support/fake-host.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The management UI's behaviour, mounted as React rather than inspected as source.
|
* The management UI's behaviour, mounted as React rather than inspected as source.
|
||||||
@@ -19,18 +14,14 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// React needs this to run effects the way a browser does.
|
// React needs this to run effects the way a browser does.
|
||||||
(
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
||||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
|
const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
|
||||||
const copiedText: string[] = [];
|
const copiedText: string[] = [];
|
||||||
|
|
||||||
function dashboard(
|
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement {
|
||||||
context: Parameters<typeof SignageDashboard>[0]["context"],
|
|
||||||
): React.ReactElement {
|
|
||||||
return (
|
return (
|
||||||
<SignageDashboard
|
<SignageDashboard
|
||||||
context={context}
|
context={context}
|
||||||
@@ -93,9 +84,7 @@ function buttonNamed(label: string): HTMLButtonElement | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GROUP = {
|
const GROUP = {
|
||||||
channels: [
|
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
|
||||||
{ id: "chan-1", isDefault: true, key: "default", name: "Default" },
|
|
||||||
],
|
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
name: "Main",
|
name: "Main",
|
||||||
playback: {
|
playback: {
|
||||||
@@ -208,12 +197,10 @@ describe("Signage dashboard", () => {
|
|||||||
|
|
||||||
await render(dashboard(host.context));
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
expect(
|
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
|
||||||
container.querySelector("[data-testid='signage-current']")?.textContent,
|
"welcome",
|
||||||
).toContain("welcome");
|
);
|
||||||
expect(
|
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
|
||||||
container.querySelector("[data-testid='signage-remaining']")?.textContent,
|
|
||||||
).toBe("4s");
|
|
||||||
|
|
||||||
await click(buttonNamed("Pause"));
|
await click(buttonNamed("Pause"));
|
||||||
await click(buttonNamed("Next"));
|
await click(buttonNamed("Next"));
|
||||||
@@ -256,12 +243,8 @@ describe("Signage dashboard", () => {
|
|||||||
await render(dashboard(host.context));
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
||||||
expect(
|
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
|
||||||
container.querySelector("[data-testid='signage-current']"),
|
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
|
||||||
).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
container.querySelector("[data-testid='signage-controls']"),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows which timeline remains active after the dashboard data is reloaded", async () => {
|
it("shows which timeline remains active after the dashboard data is reloaded", async () => {
|
||||||
@@ -349,11 +332,7 @@ describe("Signage dashboard", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
path: "playback/group-1/activate",
|
path: "playback/group-1/activate",
|
||||||
respond: () =>
|
respond: () =>
|
||||||
errorResponse(
|
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409),
|
||||||
"SIGNAGE_CONFLICT",
|
|
||||||
"The request conflicts with the current state.",
|
|
||||||
409,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -404,12 +383,7 @@ describe("Signage dashboard", () => {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "timelines",
|
path: "timelines",
|
||||||
respond: () =>
|
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
|
||||||
errorResponse(
|
|
||||||
"SIGNAGE_INVALID_REQUEST",
|
|
||||||
"The request is not valid.",
|
|
||||||
400,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -421,9 +395,7 @@ describe("Signage dashboard", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = container.querySelector(
|
const input = container.querySelector("input[type='text']") as HTMLInputElement;
|
||||||
"input[type='text']",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
await type(input, "Sunday morning");
|
await type(input, "Sunday morning");
|
||||||
await click(buttonNamed("Create"));
|
await click(buttonNamed("Create"));
|
||||||
|
|
||||||
@@ -504,9 +476,7 @@ describe("Signage dashboard", () => {
|
|||||||
);
|
);
|
||||||
await click(buttonNamed("Sunday morning"));
|
await click(buttonNamed("Sunday morning"));
|
||||||
|
|
||||||
expect(
|
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
|
||||||
container.querySelector("[data-testid='signage-timeline-editor']"),
|
|
||||||
).not.toBeNull();
|
|
||||||
expect(container.textContent).toContain("This timeline has no slots");
|
expect(container.textContent).toContain("This timeline has no slots");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -615,9 +585,7 @@ describe("Signage dashboard", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(container.textContent).toContain("No content providers registered");
|
expect(container.textContent).toContain("No content providers registered");
|
||||||
expect(container.textContent).toContain(
|
expect(container.textContent).toContain("Signage works on its own with native images");
|
||||||
"Signage works on its own with native images",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the display roster with online state and offline readiness", async () => {
|
it("shows the display roster with online state and offline readiness", async () => {
|
||||||
@@ -677,19 +645,13 @@ describe("Signage dashboard", () => {
|
|||||||
|
|
||||||
await render(dashboard(host.context));
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
||||||
(tab) => tab.textContent === "Displays",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.textContent).toContain("Online");
|
expect(container.textContent).toContain("Online");
|
||||||
expect(container.textContent).toContain("12 cached");
|
expect(container.textContent).toContain("12 cached");
|
||||||
const display = container.querySelector(
|
const display = container.querySelector("[data-testid='signage-display-viewer-1']");
|
||||||
"[data-testid='signage-display-viewer-1']",
|
expect(display?.closest(".signage-channel")?.textContent).toContain("Default");
|
||||||
);
|
|
||||||
expect(display?.closest(".signage-channel")?.textContent).toContain(
|
|
||||||
"Default",
|
|
||||||
);
|
|
||||||
// A sanitized code, never a stack or a message a browser happened to have.
|
// A sanitized code, never a stack or a message a browser happened to have.
|
||||||
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
||||||
});
|
});
|
||||||
@@ -697,9 +659,7 @@ describe("Signage dashboard", () => {
|
|||||||
it("explains display registration and deletes a channel group only after confirmation", async () => {
|
it("explains display registration and deletes a channel group only after confirmation", async () => {
|
||||||
const secondGroup = {
|
const secondGroup = {
|
||||||
...GROUP,
|
...GROUP,
|
||||||
channels: [
|
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }],
|
||||||
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
|
|
||||||
],
|
|
||||||
id: "group-2",
|
id: "group-2",
|
||||||
name: "Foyer",
|
name: "Foyer",
|
||||||
playback: { ...GROUP.playback, groupId: "group-2" },
|
playback: { ...GROUP.playback, groupId: "group-2" },
|
||||||
@@ -754,21 +714,21 @@ describe("Signage dashboard", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setupLink = [...container.querySelectorAll("a")].find(
|
const setupLink = [...container.querySelectorAll("a")].find(
|
||||||
(candidate) => candidate.textContent?.trim() === "Open display setup",
|
(candidate) => candidate.textContent?.trim() === "Preview here",
|
||||||
);
|
);
|
||||||
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
|
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
|
||||||
expect(setupLink?.target).toBe("_blank");
|
expect(setupLink?.target).toBe("_blank");
|
||||||
expect(setupLink?.rel).toContain("noopener");
|
expect(setupLink?.rel).toContain("noopener");
|
||||||
await click(buttonNamed("Copy URL"));
|
await click(buttonNamed("Copy setup address"));
|
||||||
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
|
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
|
||||||
expect(container.textContent).toContain("Display setup URL copied.");
|
expect(container.textContent).toContain("Display setup address copied.");
|
||||||
expect(container.textContent).toContain("Channel groups and displays");
|
expect(container.textContent).toContain("Channel groups and displays");
|
||||||
expect(container.textContent).toContain("Audience channels");
|
// Plain language for the audience concept, and no internal key on the page by default.
|
||||||
expect(container.textContent).toContain("Channel name");
|
expect(container.textContent).toContain("Audiences");
|
||||||
expect(container.textContent).toContain("Audience key");
|
expect(container.textContent).toContain("Images look the same for every audience.");
|
||||||
expect(container.textContent).toContain(
|
expect(container.textContent).toContain("Audience name");
|
||||||
"Advanced: lowercase ID sent to content providers",
|
expect(container.textContent).not.toContain("Audience key");
|
||||||
);
|
expect(container.querySelector("details.signage-advanced")?.hasAttribute("open")).toBe(false);
|
||||||
await click(buttonNamed("Delete group"));
|
await click(buttonNamed("Delete group"));
|
||||||
expect(deleted).toEqual([]);
|
expect(deleted).toEqual([]);
|
||||||
expect(container.textContent).toContain("Displays assigned here will move");
|
expect(container.textContent).toContain("Displays assigned here will move");
|
||||||
@@ -825,9 +785,7 @@ describe("Signage dashboard", () => {
|
|||||||
|
|
||||||
await render(dashboard(host.context));
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
|
||||||
(tab) => tab.textContent === "Images",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = container.querySelector(
|
const input = container.querySelector(
|
||||||
@@ -857,9 +815,7 @@ describe("Signage dashboard", () => {
|
|||||||
const uploads = container.querySelector("[data-testid='signage-uploads']");
|
const uploads = container.querySelector("[data-testid='signage-uploads']");
|
||||||
expect(uploads?.textContent).toContain("done");
|
expect(uploads?.textContent).toContain("done");
|
||||||
expect(uploads?.textContent).toContain("failed");
|
expect(uploads?.textContent).toContain("failed");
|
||||||
expect(uploads?.textContent).toContain(
|
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
|
||||||
"The uploaded file is not a decodable image.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays on screen when one panel's data cannot be loaded", async () => {
|
it("stays on screen when one panel's data cannot be loaded", async () => {
|
||||||
@@ -885,11 +841,7 @@ describe("Signage dashboard", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
path: "playback/group-1",
|
path: "playback/group-1",
|
||||||
respond: () =>
|
respond: () =>
|
||||||
errorResponse(
|
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
|
||||||
"SIGNAGE_OPERATION_FAILED",
|
|
||||||
"The operation could not be completed.",
|
|
||||||
500,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -897,9 +849,7 @@ describe("Signage dashboard", () => {
|
|||||||
await render(dashboard(host.context));
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
expect(container.textContent).toContain("Signage could not load this");
|
expect(container.textContent).toContain("Signage could not load this");
|
||||||
expect(container.textContent).toContain(
|
expect(container.textContent).toContain("The operation could not be completed.");
|
||||||
"The operation could not be completed.",
|
|
||||||
);
|
|
||||||
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
|
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
|
||||||
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
|
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ describe("a display that loses its network", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => {
|
respond: () => {
|
||||||
if (!reachable) {
|
if (!reachable) {
|
||||||
throw new Error("network is gone");
|
throw new Error("network is gone");
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js";
|
||||||
|
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
||||||
|
import {
|
||||||
|
createFakeHost,
|
||||||
|
FAKE_SETTINGS,
|
||||||
|
type FakeHostRoute,
|
||||||
|
jsonResponse,
|
||||||
|
} from "./support/fake-host.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 0 production-readiness regressions for the management screen.
|
||||||
|
*
|
||||||
|
* Each case is a defect an operator met: an activation that looked lost after changing tabs, a
|
||||||
|
* screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an
|
||||||
|
* internal key presented as something to fill in, and test displays nobody could remove.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
let container: HTMLElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function click(element: Element | null | undefined): Promise<void> {
|
||||||
|
if (!element) {
|
||||||
|
throw new Error("Expected an element to click.");
|
||||||
|
}
|
||||||
|
await act(async () => (element as HTMLElement).click());
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label: string): HTMLButtonElement | undefined {
|
||||||
|
return [...container.querySelectorAll("button")].find(
|
||||||
|
(candidate) => candidate.textContent?.trim() === label,
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(label: string): Element | undefined {
|
||||||
|
return [...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(candidate) => candidate.textContent === label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(select: HTMLSelectElement, value: string): Promise<void> {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||||
|
await act(async () => {
|
||||||
|
setter?.call(select, value);
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
await act(async () => {
|
||||||
|
setter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeline(id: string, name: string) {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
eligibleFrom: null,
|
||||||
|
eligibleUntil: null,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
revision: 1,
|
||||||
|
updatedAt: "2026-10-01T15:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(activeTimelineId: string | null) {
|
||||||
|
return {
|
||||||
|
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
|
||||||
|
id: "group-1",
|
||||||
|
name: "Main",
|
||||||
|
playback: {
|
||||||
|
activeTimelineId,
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: null,
|
||||||
|
currentSlotId: null,
|
||||||
|
deadlineAtMs: null,
|
||||||
|
groupId: "group-1",
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: null,
|
||||||
|
revision: activeTimelineId === null ? 1 : 2,
|
||||||
|
scheduledActivationAtMs: null,
|
||||||
|
scheduledTimelineId: null,
|
||||||
|
selectionRevision: null,
|
||||||
|
serverNowMs: 1_700_000_000_000,
|
||||||
|
startedAtMs: null,
|
||||||
|
staticAssetId: null,
|
||||||
|
transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small, stateful Signage backend: activation really changes what later reads return. */
|
||||||
|
function backend(extra: readonly FakeHostRoute[] = []) {
|
||||||
|
let active: string | null = null;
|
||||||
|
const sent: string[] = [];
|
||||||
|
const routes: FakeHostRoute[] = [
|
||||||
|
...extra,
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channel-groups",
|
||||||
|
respond: () => jsonResponse({ groups: [group(active)] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "timelines",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
|
||||||
|
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
|
||||||
|
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "playback/group-1",
|
||||||
|
respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }),
|
||||||
|
},
|
||||||
|
{ method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "playback/group-1/activate",
|
||||||
|
respond: ({ init }) => {
|
||||||
|
active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId;
|
||||||
|
sent.push(`activate ${active}`);
|
||||||
|
return jsonResponse(group(active).playback);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return { routes, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(host: ReturnType<typeof createFakeHost>) {
|
||||||
|
return act(async () => {
|
||||||
|
root.render(<SignageDashboard context={host.context} copyText={() => Promise.resolve()} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage dashboard — Stage 0 regressions", () => {
|
||||||
|
it("shows a timeline activated on Playback as active on Timelines too", async () => {
|
||||||
|
const server = backend();
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"],
|
||||||
|
routes: server.routes,
|
||||||
|
});
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const activate = [...container.querySelectorAll("select")].find((select) =>
|
||||||
|
[...select.options].some((option) => option.textContent === "Choose a timeline…"),
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await choose(activate, "timeline-2");
|
||||||
|
await click(button("Now"));
|
||||||
|
expect(server.sent).toEqual(["activate timeline-2"]);
|
||||||
|
|
||||||
|
await click(tab("Timelines"));
|
||||||
|
const row = [...container.querySelectorAll("tr")].find((candidate) =>
|
||||||
|
candidate.textContent?.includes("Easter"),
|
||||||
|
);
|
||||||
|
expect(row?.textContent).toContain("Active in Main");
|
||||||
|
expect(container.querySelector("tr")?.parentElement).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => {
|
||||||
|
let slow = false;
|
||||||
|
const server = backend([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "timelines",
|
||||||
|
respond: () =>
|
||||||
|
slow
|
||||||
|
? new Promise<Response>(() => undefined)
|
||||||
|
: jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
await click(tab("Timelines"));
|
||||||
|
expect(container.textContent).toContain("Sunday");
|
||||||
|
|
||||||
|
// The next load never finishes. Coming back must still show the last known list.
|
||||||
|
slow = true;
|
||||||
|
await click(tab("Images"));
|
||||||
|
await click(tab("Timelines"));
|
||||||
|
expect(container.textContent).toContain("Sunday");
|
||||||
|
expect(container.textContent).not.toContain("Loading timelines…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => {
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const image = {
|
||||||
|
byteSize: 1000,
|
||||||
|
contentType: "image/png",
|
||||||
|
createdAt: "2026-10-01T15:00:00.000Z",
|
||||||
|
displayName: "Welcome",
|
||||||
|
expiresAt: null,
|
||||||
|
height: 1080,
|
||||||
|
id: "image-1",
|
||||||
|
originalFilename: "welcome.png",
|
||||||
|
renditions: [],
|
||||||
|
status: "ready",
|
||||||
|
width: 1920,
|
||||||
|
};
|
||||||
|
const server = backend([
|
||||||
|
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) },
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: "images/image-1",
|
||||||
|
respond: ({ path }) => {
|
||||||
|
deleted.push(path);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.assets.manage"],
|
||||||
|
routes: server.routes,
|
||||||
|
});
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
await click(tab("Images"));
|
||||||
|
|
||||||
|
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
expect(button("Delete this image")).toBeDefined();
|
||||||
|
await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']"));
|
||||||
|
expect(button("Delete this image")).toBeUndefined();
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
|
||||||
|
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
|
||||||
|
await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']"));
|
||||||
|
expect(deleted).toEqual(["images/image-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an audience from its name alone, deriving the content key", async () => {
|
||||||
|
const posted: unknown[] = [];
|
||||||
|
const server = backend([
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "channel-groups/group-1/channels",
|
||||||
|
respond: ({ init }) => {
|
||||||
|
posted.push(JSON.parse(String(init?.body)));
|
||||||
|
return jsonResponse({}, 201);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.displays.manage"],
|
||||||
|
routes: server.routes,
|
||||||
|
});
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
await click(tab("Displays"));
|
||||||
|
|
||||||
|
const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement;
|
||||||
|
await type(name, "Watching Online");
|
||||||
|
await click(button("Add audience"));
|
||||||
|
expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a signed-in browser and removes an offline entry only after confirmation", async () => {
|
||||||
|
const removed: string[] = [];
|
||||||
|
const viewer = {
|
||||||
|
cachedAssetCount: 0,
|
||||||
|
channelId: "chan-1",
|
||||||
|
deviceId: null,
|
||||||
|
id: "viewer-1",
|
||||||
|
lastError: null,
|
||||||
|
lastSeenAtMs: Date.now() - 600_000,
|
||||||
|
name: "New display",
|
||||||
|
offlineReady: false,
|
||||||
|
online: false,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedProviderId: null,
|
||||||
|
reportedSelectionId: null,
|
||||||
|
reportedTimelineId: null,
|
||||||
|
};
|
||||||
|
const server = backend([
|
||||||
|
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) },
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: "viewers/viewer-1",
|
||||||
|
respond: ({ path }) => {
|
||||||
|
removed.push(path);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.displays.manage"],
|
||||||
|
routes: server.routes,
|
||||||
|
});
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
await click(tab("Displays"));
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Signed-in browser");
|
||||||
|
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']"));
|
||||||
|
expect(removed).toEqual([]);
|
||||||
|
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']"));
|
||||||
|
expect(removed).toEqual(["viewers/viewer-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows playback changes on the management channel, and stops when Playback is left", async () => {
|
||||||
|
const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] =
|
||||||
|
[];
|
||||||
|
const server = backend();
|
||||||
|
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
|
||||||
|
Object.assign(host.context.realtime, {
|
||||||
|
subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) {
|
||||||
|
const entry = { closed: false, onUpdate: handlers.onUpdate };
|
||||||
|
if (channel === "management-state") {
|
||||||
|
subscriptions.push(entry);
|
||||||
|
}
|
||||||
|
return { close: () => (entry.closed = true) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mount(host);
|
||||||
|
await settle();
|
||||||
|
const detailReads = () =>
|
||||||
|
host.requests.filter((request) => request.path === "playback/group-1").length;
|
||||||
|
const before = detailReads();
|
||||||
|
|
||||||
|
const live = subscriptions.find((entry) => !entry.closed);
|
||||||
|
await act(async () => {
|
||||||
|
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] });
|
||||||
|
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] });
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(detailReads()).toBeGreaterThan(before);
|
||||||
|
|
||||||
|
await click(tab("Images"));
|
||||||
|
expect(subscriptions.every((entry) => entry.closed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audience content keys", () => {
|
||||||
|
it("derives a valid, unique key that is never the reserved default", () => {
|
||||||
|
expect(audienceKeyFor("Online", [])).toBe("online");
|
||||||
|
expect(audienceKeyFor("Online", ["online"])).toBe("online-2");
|
||||||
|
expect(audienceKeyFor("Default", [])).toBe("default-2");
|
||||||
|
expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service");
|
||||||
|
expect(audienceKeyFor(" ", [])).toBe("audience");
|
||||||
|
expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */
|
||||||
|
describe("product rules in the browser source", () => {
|
||||||
|
function sources(directory: string): string[] {
|
||||||
|
return readdirSync(directory).flatMap((entry) => {
|
||||||
|
const path = join(directory, entry);
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
return sources(path);
|
||||||
|
}
|
||||||
|
return /\.(ts|tsx)$/u.test(entry) ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const files = ["src/ui", "src/browser", "src/display"].flatMap(sources);
|
||||||
|
|
||||||
|
it("never uses a browser-native alert, confirm, or prompt", () => {
|
||||||
|
for (const file of files) {
|
||||||
|
expect(readFileSync(file, "utf8"), file).not.toMatch(
|
||||||
|
/\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts the platform's name in user-facing text", () => {
|
||||||
|
for (const file of files) {
|
||||||
|
const code = readFileSync(file, "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gmu, "")
|
||||||
|
.replace(/\{\/\*[\s\S]*?\*\/\}/gu, "");
|
||||||
|
// Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text.
|
||||||
|
expect(code, file).not.toMatch(
|
||||||
|
/["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,11 +37,7 @@ export function jsonResponse(body: unknown, status = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorResponse(
|
export function errorResponse(code: string, message: string, status = 400): Response {
|
||||||
code: string,
|
|
||||||
message: string,
|
|
||||||
status = 400,
|
|
||||||
): Response {
|
|
||||||
return jsonResponse({ code, message }, status);
|
return jsonResponse({ code, message }, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,19 +70,13 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
|
|||||||
return route.respond({ init, path: normalized });
|
return route.respond({ init, path: normalized });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errorResponse(
|
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
||||||
"SIGNAGE_NOT_FOUND",
|
|
||||||
"The requested resource was not found.",
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const contributions: ModuleUiContributions = {
|
const contributions: ModuleUiContributions = {
|
||||||
list: (capabilityId) =>
|
list: (capabilityId) =>
|
||||||
capabilityId === "signage.slide-renderer"
|
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
||||||
? (options.contributions ?? [])
|
|
||||||
: [],
|
|
||||||
async mount(mountOptions) {
|
async mount(mountOptions) {
|
||||||
mounted.push({
|
mounted.push({
|
||||||
contributionId: mountOptions.contributionId,
|
contributionId: mountOptions.contributionId,
|
||||||
@@ -127,7 +117,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
|
|||||||
/** The settings payload every screen loads first. */
|
/** The settings payload every screen loads first. */
|
||||||
export const FAKE_SETTINGS = Object.freeze({
|
export const FAKE_SETTINGS = Object.freeze({
|
||||||
crossfadeMs: 1000,
|
crossfadeMs: 1000,
|
||||||
moduleVersion: "0.1.3",
|
moduleVersion: "0.1.4",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
database: {
|
database: {
|
||||||
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
|
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
|
||||||
|
operation(transaction),
|
||||||
} as unknown as ModuleDrizzleDatabase,
|
} as unknown as ModuleDrizzleDatabase,
|
||||||
positions,
|
positions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
|
|||||||
expect(manifest.manifestVersion).toBe(8);
|
expect(manifest.manifestVersion).toBe(8);
|
||||||
expect(manifest.id).toBe("signage");
|
expect(manifest.id).toBe("signage");
|
||||||
expect(manifest.name).toBe("Signage");
|
expect(manifest.name).toBe("Signage");
|
||||||
expect(manifest.version).toBe("0.1.3");
|
expect(manifest.version).toBe("0.1.4");
|
||||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
|
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ describe("Signage module manifest", () => {
|
|||||||
describe("Signage server runtime", () => {
|
describe("Signage server runtime", () => {
|
||||||
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||||
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
|
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
|
||||||
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.3" });
|
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.4" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||||
@@ -177,6 +177,7 @@ describe("Signage server runtime", () => {
|
|||||||
"GET /timelines",
|
"GET /timelines",
|
||||||
"GET /timelines/:timelineId",
|
"GET /timelines/:timelineId",
|
||||||
"GET /viewers",
|
"GET /viewers",
|
||||||
|
"GET /viewers/:viewerId/display-state",
|
||||||
"PATCH /images/:imageId",
|
"PATCH /images/:imageId",
|
||||||
"PATCH /timelines/:timelineId",
|
"PATCH /timelines/:timelineId",
|
||||||
"PATCH /timelines/:timelineId/slots/:slotId",
|
"PATCH /timelines/:timelineId/slots/:slotId",
|
||||||
@@ -250,7 +251,7 @@ describe("Signage server runtime", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
crossfadeMs: 400,
|
crossfadeMs: 400,
|
||||||
moduleVersion: "0.1.3",
|
moduleVersion: "0.1.4",
|
||||||
nativeImageDurationMs: 12_000,
|
nativeImageDurationMs: 12_000,
|
||||||
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
||||||
rendererInputVersion: "1.0.0",
|
rendererInputVersion: "1.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user