Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3e73824c | ||
|
|
6d636190cd | ||
|
|
d2c087a256 | ||
|
|
db6467132e | ||
|
|
d567f5459a | ||
|
|
a71b14d755 | ||
|
|
4c5d7956ab | ||
|
|
01eac1bdc4 | ||
|
|
10ac7d1e09 | ||
|
|
20dc2deb2b | ||
|
|
106ce0c1ce | ||
|
|
706cab2362 | ||
|
|
5fe39539fc | ||
|
|
35ef54e248 | ||
|
|
1d46657e06 | ||
|
|
7e75146838 | ||
|
|
dba10a30fe | ||
|
|
64502261ff | ||
|
|
6427840223 | ||
|
|
22aa2f0e6d | ||
|
|
2daf8897a1 | ||
|
|
50caf02023 | ||
|
|
e7dc235a8a | ||
|
|
a3a68ebd73 | ||
|
|
483c353843 | ||
|
|
a68aed7443 | ||
|
|
b95aa7dc34 | ||
|
|
66f45609f0 | ||
|
|
c4db412014 | ||
|
|
c3d8483943 | ||
|
|
1292ba33ba | ||
|
|
7ff8b42e90 | ||
|
|
cb77030eec | ||
|
|
d5cb6b6cd9 | ||
|
|
444f03938d | ||
|
|
f80790e3a1 | ||
|
|
18ab8e92f1 | ||
|
|
a06bc921ce | ||
|
|
6412dd9524 |
@@ -7,3 +7,6 @@ dist/
|
|||||||
# runtime state written by the admin page / layout saves
|
# runtime state written by the admin page / layout saves
|
||||||
config/modules.json
|
config/modules.json
|
||||||
config/layouts/
|
config/layouts/
|
||||||
|
|
||||||
|
# compiled per-machine, source sits next to it
|
||||||
|
modules/propresenter-timers/ltc-capture
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
Modular production dashboard web app for Waters Church, developed in this repo.
|
||||||
The app lives at the repo root and is built: zero-dependency Node server
|
The app lives at the repo root and is built: zero-dependency Node server
|
||||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||||
`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the
|
`/admin`, modules in `modules/`. Runtime state (module config, named layouts)
|
||||||
|
is written to a per-machine data directory outside the checkout
|
||||||
|
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never
|
||||||
|
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the
|
||||||
initial build; [README.md](README.md) has run/configure/use instructions.
|
initial build; [README.md](README.md) has run/configure/use instructions.
|
||||||
|
|
||||||
## Building a new module — read docs/MODULE-GUIDE.md first
|
## Building a new module — read docs/MODULE-GUIDE.md first
|
||||||
@@ -13,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry,
|
|||||||
title-bar controls). The rules that matter most:
|
title-bar controls). The rules that matter most:
|
||||||
|
|
||||||
- A module is one self-contained folder in `modules/` — **never modify the
|
- A module is one self-contained folder in `modules/` — **never modify the
|
||||||
shell** (`server.js`, `public/`) to add one. Installation = drop the folder,
|
shell** (`server.js`, `public/`) to add one. Installation = the admin page's
|
||||||
restart, configure in `/admin`.
|
Available modules (downloads from the repo into the data directory), or
|
||||||
|
drop the folder and restart; configure in `/admin`.
|
||||||
|
- **All settings in the admin page** (`configSchema` / `instanceSchema`) —
|
||||||
|
never a separate setup page, hand-edited file or env var. **Dependencies
|
||||||
|
ship inside the module folder** (zero deps is the norm). **Everything the
|
||||||
|
module does runs inside its server entry** — no helper scripts, companion
|
||||||
|
programs or daemons the operator starts by hand; a listener or decoder is
|
||||||
|
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
|
||||||
|
and bump the module's `version` on every change.
|
||||||
- Browsers never call upstream services directly; the module's server routes
|
- Browsers never call upstream services directly; the module's server routes
|
||||||
proxy everything (no CORS, secrets stay server-side).
|
proxy everything (no CORS, secrets stay server-side).
|
||||||
- Style with the shell's CSS variables only, scoped under
|
- Style with the shell's CSS variables only, scoped under
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
|
|||||||
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
||||||
live updates over SSE, so browsers never talk to production gear
|
live updates over SSE, so browsers never talk to production gear
|
||||||
directly (no CORS, and API keys never leave the server).
|
directly (no CORS, and API keys never leave the server).
|
||||||
4. Stores server-wide module config and named layouts under `config/`.
|
4. Stores server-wide module config and named layouts in a per-machine data
|
||||||
|
directory outside the app folder, so updating ProdDash never loses them.
|
||||||
|
|
||||||
Each browser arranges its own tiles (add, drag, resize, remove, per-tile
|
Each browser arranges its own tiles (add, drag, resize, remove, per-tile
|
||||||
settings); the layout autosaves to that browser's `localStorage`. Named
|
settings); the layout autosaves to that browser's `localStorage`. Named
|
||||||
@@ -47,28 +48,113 @@ enable/disable) live in the **admin page** — open **Admin** from the
|
|||||||
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
dashboard's top bar, or go to `/admin`. Saving re-initialises the module
|
||||||
immediately; open tiles reconnect on their own.
|
immediately; open tiles reconnect on their own.
|
||||||
|
|
||||||
Shell settings live in [config/proddash.json](config/proddash.json):
|
### Where settings are kept
|
||||||
|
|
||||||
|
Everything you enter in the admin page (module connection settings, API keys,
|
||||||
|
enabled/disabled flags) and every named layout is written to a **data
|
||||||
|
directory on the machine running the server** — not into the app folder:
|
||||||
|
|
||||||
|
| OS | Data directory |
|
||||||
|
| --- | --- |
|
||||||
|
| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\<you>\AppData\Roaming\ProdDash`) |
|
||||||
|
| macOS | `~/Library/Application Support/ProdDash` |
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` |
|
||||||
|
|
||||||
|
Because it lives outside the checkout, you can `git pull`, re-clone, or reset
|
||||||
|
the app folder from `main` and start the new version with everything still
|
||||||
|
configured. The admin page shows the exact path in use, and the server prints
|
||||||
|
it at startup (`Settings : …`).
|
||||||
|
|
||||||
|
The directory holds `modules.json` (module config), `layouts/` (named
|
||||||
|
layouts) and `modules/` (modules installed from the admin page), all written
|
||||||
|
by the app itself. It may also hold a `proddash.json`
|
||||||
|
with this machine's shell settings — the same keys as the checked-in
|
||||||
|
[config/proddash.json](config/proddash.json), which supplies the defaults:
|
||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`) |
|
| `port` | Port ProdDash serves on (default `24500`) |
|
||||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||||
|
|
||||||
Environment variables `PORT` and `PRODDASH_PASSCODE` override the file.
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`config/modules.json` (module config) and `config/layouts/` (named layouts)
|
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
are written by the app itself.
|
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
||||||
|
in-repo location). If the default directory can't be created, the server says
|
||||||
|
so and falls back to the app's `config/` folder.
|
||||||
|
|
||||||
|
**Upgrading from an earlier version:** the first start copies an existing
|
||||||
|
`config/modules.json` and `config/layouts/` from the app folder into the data
|
||||||
|
directory, so nothing has to be re-entered. The old files are left in place and
|
||||||
|
ignored from then on.
|
||||||
|
|
||||||
|
## Installing modules and updating
|
||||||
|
|
||||||
|
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
||||||
|
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
|
||||||
|
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
|
||||||
|
`owner/repo#branch`). It downloads one small archive of the branch, at most
|
||||||
|
every few minutes, only while the admin page is open or for the update check
|
||||||
|
below. Nothing is installed without someone clicking.
|
||||||
|
|
||||||
|
- **Available modules** lists modules in the repo that aren't installed
|
||||||
|
here. **Install** downloads the module into the data directory's `modules/`
|
||||||
|
and starts it right away — nothing is written into the app folder, and no
|
||||||
|
restart is needed. Modules that ship with your copy of ProdDash are
|
||||||
|
"bundled"; ones you installed are "installed"; if both exist, the newer
|
||||||
|
version runs.
|
||||||
|
- **Uninstall** on a module card removes an installed module's files. For a
|
||||||
|
bundled module (its folder belongs to the app) it hides the module from
|
||||||
|
every dashboard instead; it reappears under Available modules and can be
|
||||||
|
brought back without downloading. Settings are kept either way.
|
||||||
|
- **Module updates**: when the repo has a newer version of an installed
|
||||||
|
module, its card shows **Update to vX**.
|
||||||
|
- **Version requirements**: every module declares the ProdDash versions it
|
||||||
|
needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a
|
||||||
|
newer shell is listed but not loaded, and cannot be installed until
|
||||||
|
ProdDash is updated.
|
||||||
|
- **ProdDash updates**: the admin page checks the repo shortly after start
|
||||||
|
and every six hours, and shows a banner when `main` has a newer version
|
||||||
|
(or new commits). **Update now** brings the app folder up to date and
|
||||||
|
restarts the server; dashboards reconnect on their own. A git checkout is
|
||||||
|
updated with `git pull --ff-only` (only when it is on the repo branch with
|
||||||
|
no local changes — otherwise the banner says why and you update with git).
|
||||||
|
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||||
|
removed upstream are left behind but harmless. Settings and layouts are
|
||||||
|
never touched. **What changed** opens the commit comparison on GitHub.
|
||||||
|
- **Restarting**: after an update the server exits with code 75 and starts
|
||||||
|
its own replacement, which waits for the port to free up. If you supervise
|
||||||
|
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||||
|
just exits and your launcher restarts it on exit code 75.
|
||||||
|
`PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page
|
||||||
|
still checks when opened).
|
||||||
|
|
||||||
## Using the dashboard
|
## Using the dashboard
|
||||||
|
|
||||||
- **+** — add any enabled module; multiple tiles of the same module are
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||||
hover the seam and a small grab pill appears; drag it to trade width
|
appears on its **bottom edge** — drag it to change just the height. If
|
||||||
between the two tiles (macOS split-view style).
|
another tile sits directly below, the pill moves the shared edge instead,
|
||||||
|
trading rows between the two so the column keeps its total height. Tiles
|
||||||
|
sit flush — when two share a vertical edge, hover the seam and the same
|
||||||
|
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||||
|
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||||
|
green lip at the top-left brings it back, minus the Admin button), the
|
||||||
|
occupied columns stretch to the full width, and every column runs to the
|
||||||
|
bottom of the screen — the last tile in each column is stretched to end
|
||||||
|
there. A short tile like the clock is never stretched into a ribbon: under
|
||||||
|
another tile it slides to the bottom edge and the tile above grows into the
|
||||||
|
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
||||||
|
reverts when you leave fullscreen. A layout deeper than the screen keeps
|
||||||
|
its normal row height and scrolls.
|
||||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||||
for a clean wall-display look.
|
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||||
|
name itself**, wrapped in a translucent green pill at the left — click it
|
||||||
|
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||||
|
the bar back. It stays at the left so it never sits on top of a tile's
|
||||||
|
own tab.
|
||||||
- The **gear** on a tile holds per-tile settings (filters, text size,
|
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- The **grid icon** — save the current arrangement to the server under a
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
@@ -83,24 +169,53 @@ are written by the app itself.
|
|||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
speech, channel colors, per-tile channel visibility toggles and group
|
speech, channel colors, per-tile channel visibility toggles and group
|
||||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||||
Admin config: ProdCom URL, API key.
|
Admin config: ProdCom server (host : port), API key.
|
||||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
port, password.
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
|
device and channel), and **Display** (Timer font — Default or Monospace,
|
||||||
|
which keeps the digits from shifting as the numbers change).
|
||||||
|
|
||||||
|
ProPresenter exposes no timecode over any API, so LTC is decoded from the
|
||||||
|
audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile
|
||||||
|
in the picker at all). On, the module captures the selected audio input
|
||||||
|
device/channel on the ProdDash machine — a native `ltc-capture` helper,
|
||||||
|
compiled on first use — and decodes the SMPTE frames in-process:
|
||||||
|
running/stopped/no-signal, the timecode counting frame-accurately in real
|
||||||
|
time, and the frame rate (incl. 29.97 drop-frame). macOS note: the
|
||||||
|
ProdDash server needs microphone permission — launch it from a logged-in
|
||||||
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
|
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||||
|
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||||
|
plan item with its scheduled start time, length, type, song key,
|
||||||
|
description and item notes. When the ProPresenter module is running it
|
||||||
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
|
what's done and shows the elapsed vs planned runtime and how far
|
||||||
|
ahead/behind the service is. Admin config is the connection: Planning
|
||||||
|
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||||
|
token), the service type (a list grouped by your Planning Center folders)
|
||||||
|
and an optional pinned plan. Everything about the display is per tile
|
||||||
|
(gear menu): the top-bar text (placeholders such as
|
||||||
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
|
show/hide checklist for every detail.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
To develop modules against mocks instead of real gear:
|
To develop modules against mocks instead of real gear:
|
||||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||||
`127.0.0.1:1599`) and `node tools/pp-timers-mock.js 1600` (ProPresenter
|
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||||
timers + timecode on `127.0.0.1:1600`). See the testing section of the
|
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
|
||||||
|
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
|
||||||
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
|
||||||
[module guide](docs/MODULE-GUIDE.md).
|
[module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
@@ -130,7 +245,9 @@ ProdDash/
|
|||||||
│ ├── clock/
|
│ ├── clock/
|
||||||
│ ├── prodcom-transcript/
|
│ ├── prodcom-transcript/
|
||||||
│ └── propresenter-now-next/ (carries its own copy of propresenter-core)
|
│ └── propresenter-now-next/ (carries its own copy of propresenter-core)
|
||||||
├── config/ proddash.json + state written by the app
|
├── package.json name + version (what the update check compares)
|
||||||
|
├── config/ proddash.json defaults (runtime state lives in the
|
||||||
|
│ per-machine data directory — see "Configure")
|
||||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||||
├── prodcom-listener/ reference fork — read-only, not served
|
├── prodcom-listener/ reference fork — read-only, not served
|
||||||
|
|||||||
Regular → Executable
+7
@@ -1,4 +1,11 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# Double-click to start ProdDash (macOS)
|
# Double-click to start ProdDash (macOS)
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
# One dashboard per machine: take the port over from any prior instance
|
||||||
|
# (an SSH- or launchd-started one has no microphone access for the LTC
|
||||||
|
# listener — launching from here fixes that).
|
||||||
|
OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null)
|
||||||
|
[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1
|
||||||
|
# Prefer the bundled runtime (production machines have no system Node)
|
||||||
|
if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": ""
|
"adminPasscode": "",
|
||||||
|
"repo": "michaelmcgary2008/ProdDash",
|
||||||
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-11
@@ -9,6 +9,31 @@ their own dashboards, move and resize them, and give each tile its own
|
|||||||
per-tile settings. Your module renders inside the tile and talks to the
|
per-tile settings. Your module renders inside the tile and talks to the
|
||||||
outside world only through the small API the shell hands it.
|
outside world only through the small API the shell hands it.
|
||||||
|
|
||||||
|
## Rules every module follows
|
||||||
|
|
||||||
|
These are not style preferences — the admin page, the installer and the
|
||||||
|
people running the booth depend on them.
|
||||||
|
|
||||||
|
1. **All settings live in the ProdDash admin page.** Server-wide settings go
|
||||||
|
in `configSchema`, per-tile settings in `instanceSchema`. Never a separate
|
||||||
|
setup page, wizard, config file the operator edits by hand, or environment
|
||||||
|
variable.
|
||||||
|
2. **Dependencies ship inside the module folder.** Zero dependencies is the
|
||||||
|
norm (the shell has none). Anything you genuinely need is vendored into
|
||||||
|
the module — or fetched by your own `init()` on first start, with the
|
||||||
|
admin health line saying what is happening. No `npm install`, no
|
||||||
|
installer, no "run this first".
|
||||||
|
3. **Everything the module does runs inside the module.** No helper scripts,
|
||||||
|
companion programs, daemons or services the operator has to start next to
|
||||||
|
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
||||||
|
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
||||||
|
it in `health()`. A child process the module itself spawns and supervises
|
||||||
|
is fine; a program someone launches by hand is not.
|
||||||
|
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||||||
|
manifest) and **bump your own `version`** whenever you change the module.
|
||||||
|
The admin page refuses to install a module whose requirement the running
|
||||||
|
shell doesn't meet, and it offers updates by comparing versions.
|
||||||
|
|
||||||
## The shape of a module
|
## The shape of a module
|
||||||
|
|
||||||
A module is one folder dropped into `modules/`:
|
A module is one folder dropped into `modules/`:
|
||||||
@@ -21,9 +46,16 @@ modules/<id>/
|
|||||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Installation is exactly this:** copy the folder into `modules/`, restart
|
**Installation is exactly this:** in the admin page, open **Available
|
||||||
ProdDash (`node server.js`), then configure and enable it at `/admin`. No
|
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||||
shell code changes, ever.
|
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||||||
|
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||||||
|
with the checkout) or into the data directory's `modules/` and restart. No
|
||||||
|
shell code changes, ever. Modules bundled with a checkout and modules
|
||||||
|
installed from the admin page live side by side; when both have the same id
|
||||||
|
the newer version runs. **Uninstall** in the admin page removes an installed
|
||||||
|
module's files (a bundled one is hidden instead, since its folder belongs to
|
||||||
|
the app); its settings are kept for a later reinstall.
|
||||||
|
|
||||||
## module.json
|
## module.json
|
||||||
|
|
||||||
@@ -32,6 +64,7 @@ shell code changes, ever.
|
|||||||
"id": "my-module",
|
"id": "my-module",
|
||||||
"name": "My Module",
|
"name": "My Module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "One line shown in the Add-tile picker and the admin page",
|
"description": "One line shown in the Add-tile picker and the admin page",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -52,7 +85,9 @@ shell code changes, ever.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||||||
| `name` | yes | Human name shown on the tile header and in menus. |
|
| `name` | yes | Human name shown on the tile header and in menus. |
|
||||||
| `version`, `description` | no | Shown in the admin page / picker. |
|
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
||||||
|
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||||||
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
@@ -64,21 +99,112 @@ shell code changes, ever.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "select" | "password",
|
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"group": "Section heading", // optional; see below
|
||||||
|
"showWhen": "<other boolean/switch key>", // optional; see below
|
||||||
|
"help": "One line shown under the control", // optional, admin config only
|
||||||
|
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||||||
|
"optionsRoute": "/devices", // "select" only, admin config only
|
||||||
|
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`boolean` renders as a checkbox; `switch` is the same true/false value shown
|
||||||
|
as a toggle (use it for a feature on/off that other fields depend on).
|
||||||
|
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||||
|
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||||
|
|
||||||
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
|
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
||||||
|
with no `group` render loose, before/after the grouped ones in schema order.
|
||||||
|
|
||||||
|
A manifest-level `configGroups` map can dress a group up further, keyed by
|
||||||
|
the group name:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
|
||||||
|
"Show / hide": { "collapsed": true, "columns": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
|
||||||
|
or `"whenSet"` — folded once the group already holds values (a saved
|
||||||
|
password, or anything other than the schema default), open while it is
|
||||||
|
still empty. Omit it and the group is a plain, always-open block.
|
||||||
|
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||||||
|
long checklist of booleans compact.
|
||||||
|
- `help`: a line of guidance at the top of the group.
|
||||||
|
|
||||||
|
`help` on a field puts one line of guidance under its control — allowed
|
||||||
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
|
explanation there.
|
||||||
|
|
||||||
|
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||||
|
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||||
|
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
|
||||||
|
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
|
||||||
|
Display choices belong there, per tile; admin config is for connections.
|
||||||
|
|
||||||
|
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||||||
|
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||||
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
|
|
||||||
|
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||||||
|
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||||
|
refresh of — static `options`: the admin page GETs
|
||||||
|
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
|
||||||
|
"label" }, … ] }` from one of the module's own server routes. The saved value
|
||||||
|
stays selectable even when the route is down or the option has vanished. (See
|
||||||
|
`propresenter-timers`' "Audio device" for a worked example.)
|
||||||
|
|
||||||
|
An option may carry a `group`: options sharing one render under a single
|
||||||
|
`<optgroup>`, so a tree (Planning Center folders, device classes) is
|
||||||
|
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
|
||||||
|
page fetch the options with that other field's current value appended as a
|
||||||
|
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
|
||||||
|
a plan list that follows the chosen service type. (Both in `pco-plan`.)
|
||||||
|
|
||||||
`password` fields are admin-config only in practice: their values are stored
|
`password` fields are admin-config only in practice: their values are stored
|
||||||
in `config/modules.json`, handed to your *server* entry, and **never sent to
|
in the server's `modules.json` (in the per-machine data directory, see the
|
||||||
browsers** — not in `/api/modules`, and never echoed back into the admin
|
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||||||
form. Anything upstream that needs the secret must go through your server
|
form. Anything upstream that needs the secret must go through your server
|
||||||
routes.
|
routes.
|
||||||
|
|
||||||
Admin config lives in `config/modules.json` (written by the admin page —
|
### Connection settings: use `endpoint`
|
||||||
your module never touches that file). Per-tile settings live inside each
|
|
||||||
|
Anything that points at a device or service on the network — ProPresenter,
|
||||||
|
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||||||
|
module's connection is entered the same way. The admin page renders an
|
||||||
|
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||||||
|
handed to your server as an object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"default": { "host": "", "port": 1025 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in your server entry
|
||||||
|
const { host, port } = config.propresenter;
|
||||||
|
const base = `http://${host}:${port}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** split host and port into separate `string`/`number` fields, and
|
||||||
|
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||||||
|
web address where protocol or path matter (a page to embed, a webhook).
|
||||||
|
Endpoints belong in admin config, not per-tile settings — connections are
|
||||||
|
server-wide by design.
|
||||||
|
|
||||||
|
Admin config lives in `modules.json` inside the server's data directory
|
||||||
|
(written by the admin page — your module never touches that file, and it
|
||||||
|
survives updates of the app folder). Per-tile settings live inside each
|
||||||
browser's layout.
|
browser's layout.
|
||||||
|
|
||||||
## Client entry (`client.js`)
|
## Client entry (`client.js`)
|
||||||
@@ -432,7 +558,18 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
||||||
overrunning, stopped and elapsed timers plus an LTC timecode that
|
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||||||
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||||||
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
|
a timecode API). Also serves the stage-display websocket with an
|
||||||
|
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
|
||||||
|
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||||||
|
- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
|
||||||
|
types, plans (last week / today / next week), plan times and items with
|
||||||
|
songs, keys and item notes, behind a Basic/Bearer auth check. Planning
|
||||||
|
Center is a cloud API, so the module reads its base URL from the
|
||||||
|
environment instead of admin config: run ProdDash with
|
||||||
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
||||||
|
Application ID + Secret under PCO Plan in `/admin`, save, then pick
|
||||||
|
"Sunday Service" from the Service type list. Its item titles match
|
||||||
|
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
|
||||||
|
|
||||||
**The resilience checklist** — every module must pass this before it's done:
|
**The resilience checklist** — every module must pass this before it's done:
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "clock",
|
"id": "clock",
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
"description": "Big booth clock — the smallest possible ProdDash module",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
|
The server part reads Planning Center and (optionally) follows the
|
||||||
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
|
streamed. What is shown is this tile's own choice (gear menu →
|
||||||
|
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
||||||
|
colors, text size); /admin only holds the connection settings. */
|
||||||
|
|
||||||
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let skew = 0; // server clock − local clock, ms
|
||||||
|
let feedOffline = false;
|
||||||
|
let stream = null;
|
||||||
|
let ticker = null;
|
||||||
|
let lastCurrentId = null;
|
||||||
|
let lastListKey = '';
|
||||||
|
let userScrolledAt = 0;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pp-wrap">
|
||||||
|
<header class="pp-head">
|
||||||
|
<div class="pp-plan">
|
||||||
|
<div class="pp-plan-title"></div>
|
||||||
|
<div class="pp-plan-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-clock">
|
||||||
|
<div class="pp-clock-label"></div>
|
||||||
|
<div class="pp-clock-value"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="pp-list"></div>
|
||||||
|
<footer class="pp-foot" hidden></footer>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = root.querySelector('.pp-wrap');
|
||||||
|
const headEl = root.querySelector('.pp-head');
|
||||||
|
const planTitleEl = root.querySelector('.pp-plan-title');
|
||||||
|
const planSubEl = root.querySelector('.pp-plan-sub');
|
||||||
|
const clockLabelEl = root.querySelector('.pp-clock-label');
|
||||||
|
const clockValueEl = root.querySelector('.pp-clock-value');
|
||||||
|
const listEl = root.querySelector('.pp-list');
|
||||||
|
const footEl = root.querySelector('.pp-foot');
|
||||||
|
|
||||||
|
/** This tile's display settings (gear menu) — read fresh on every render. */
|
||||||
|
const cfg = () => moduleApi.instanceSettings || {};
|
||||||
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||||
|
const tilePrefs = cfg;
|
||||||
|
const serverNow = () => Date.now() + skew;
|
||||||
|
|
||||||
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const refreshBtn = moduleApi.header.addButton({
|
||||||
|
icon: REFRESH_SVG,
|
||||||
|
title: 'Re-read the plan from Planning Center now',
|
||||||
|
onClick() {
|
||||||
|
refreshBtn.classList.add('active');
|
||||||
|
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||||
|
.catch(() => { /* the stream reports the outcome */ })
|
||||||
|
.finally(() => refreshBtn.classList.remove('active'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function bumpTextSize(delta) {
|
||||||
|
const size = Number(tilePrefs().textSize) || 14;
|
||||||
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||||
|
applyPrefs();
|
||||||
|
}
|
||||||
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
|
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
|
/* ── formatting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function fmtClock(ms) {
|
||||||
|
if (!ms) return '';
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(totalSec, { signed = false } = {}) {
|
||||||
|
const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
|
||||||
|
const s = Math.abs(Math.round(totalSec));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMinutes(totalSec) {
|
||||||
|
const m = Math.round(totalSec / 60);
|
||||||
|
if (m < 60) return `${m} min`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rest = m % 60;
|
||||||
|
return rest ? `${h} h ${rest} min` : `${h} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(itemType) {
|
||||||
|
switch (itemType) {
|
||||||
|
case 'song': return 'Song';
|
||||||
|
case 'media': return 'Media';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannedLength() {
|
||||||
|
return (state?.items || [])
|
||||||
|
.filter((it) => it.servicePosition === 'during')
|
||||||
|
.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── which items to show ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
const items = Array.isArray(state?.items) ? state.items : [];
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
||||||
|
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
||||||
|
if (it.servicePosition === 'post' && !on('showPostService')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteFilter() {
|
||||||
|
const raw = String(cfg().noteCategories || '');
|
||||||
|
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── status dot ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function renderStatus() {
|
||||||
|
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
|
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||||
|
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
||||||
|
if (!state.reachable) {
|
||||||
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||||
|
}
|
||||||
|
const live = state.live || {};
|
||||||
|
let extra = '';
|
||||||
|
if (live.following) {
|
||||||
|
extra = live.available
|
||||||
|
? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
|
||||||
|
: ` · ${live.reason || 'ProPresenter feed unavailable'}`;
|
||||||
|
}
|
||||||
|
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top bar: admin template with placeholders ──────────────────── */
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
|
||||||
|
|
||||||
|
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
||||||
|
function templateValues() {
|
||||||
|
const plan = state?.plan || {};
|
||||||
|
const items = state?.items || [];
|
||||||
|
const planned = plannedLength();
|
||||||
|
return {
|
||||||
|
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
||||||
|
part: on('showPart') ? plan.title || '' : '',
|
||||||
|
title: on('showPart') ? plan.title || '' : '',
|
||||||
|
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
|
||||||
|
date: on('showDate') ? plan.dates || '' : '',
|
||||||
|
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
||||||
|
time: on('showServiceTime') && plan.serviceStartsAt
|
||||||
|
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
|
||||||
|
: '',
|
||||||
|
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
|
||||||
|
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
|
||||||
|
* line, • (or ·) separates segments; a segment whose placeholders all came
|
||||||
|
* out empty is dropped, so a plan with no series just reads "Wk 2".
|
||||||
|
*/
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
|
||||||
|
return lines.map((line) => {
|
||||||
|
const segments = line.split(/\s*[•·]\s*/);
|
||||||
|
const kept = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
let sawVar = false;
|
||||||
|
let allEmpty = true;
|
||||||
|
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
||||||
|
sawVar = true;
|
||||||
|
const v = values[name.toLowerCase()];
|
||||||
|
if (v) allEmpty = false;
|
||||||
|
return v || '';
|
||||||
|
}).trim();
|
||||||
|
if (sawVar && allEmpty) continue;
|
||||||
|
if (text) kept.push(text);
|
||||||
|
}
|
||||||
|
return kept.join(' • ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHead() {
|
||||||
|
const plan = state?.plan;
|
||||||
|
const show = on('showTopBar') && plan;
|
||||||
|
headEl.hidden = !show;
|
||||||
|
if (!show) return;
|
||||||
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||||
|
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
|
||||||
|
planSubEl.textContent = line1 ? line2 || '' : '';
|
||||||
|
planSubEl.hidden = !planSubEl.textContent;
|
||||||
|
renderClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClock() {
|
||||||
|
const plan = state?.plan;
|
||||||
|
const live = state?.live || {};
|
||||||
|
clockLabelEl.textContent = '';
|
||||||
|
clockValueEl.textContent = '';
|
||||||
|
clockValueEl.className = 'pp-clock-value';
|
||||||
|
if (!plan || headEl.hidden) return;
|
||||||
|
const now = serverNow();
|
||||||
|
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
|
||||||
|
clockLabelEl.textContent = 'Running';
|
||||||
|
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
||||||
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||||
|
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
||||||
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
|
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
||||||
|
const drift = actual - planned;
|
||||||
|
if (planned && Math.abs(drift) >= 30) {
|
||||||
|
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
||||||
|
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
||||||
|
clockLabelEl.textContent = 'Starts in';
|
||||||
|
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on('showRunningClock') && plan.serviceStartsAt) {
|
||||||
|
clockLabelEl.textContent = 'Since start';
|
||||||
|
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the list ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function emptyMessage() {
|
||||||
|
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
||||||
|
if (!state) return { title: 'Connecting…', hint: '' };
|
||||||
|
if (!state.configured) {
|
||||||
|
return {
|
||||||
|
title: 'Planning Center not connected',
|
||||||
|
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
||||||
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!state.serviceTypeSet) {
|
||||||
|
return {
|
||||||
|
title: 'No service type selected',
|
||||||
|
hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
|
||||||
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!state.reachable && !state.plan) {
|
||||||
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||||
|
}
|
||||||
|
if (!state.plan) {
|
||||||
|
return {
|
||||||
|
title: 'No plan found',
|
||||||
|
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(item, number, notesWanted) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pp-row';
|
||||||
|
row.dataset.id = item.id;
|
||||||
|
if (item.itemType === 'header') row.classList.add('is-header');
|
||||||
|
else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
|
||||||
|
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
||||||
|
const isHeader = item.itemType === 'header';
|
||||||
|
|
||||||
|
if (on('showItemNumbers')) {
|
||||||
|
const numCol = document.createElement('div');
|
||||||
|
numCol.className = 'pp-col-num';
|
||||||
|
numCol.textContent = isHeader ? '' : String(number);
|
||||||
|
row.appendChild(numCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (on('showStartTimes')) {
|
||||||
|
const timeCol = document.createElement('div');
|
||||||
|
timeCol.className = 'pp-col-time';
|
||||||
|
timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
|
||||||
|
row.appendChild(timeCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'pp-col-main';
|
||||||
|
const titleLine = document.createElement('div');
|
||||||
|
titleLine.className = 'pp-title-line';
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'pp-title';
|
||||||
|
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
||||||
|
titleLine.appendChild(titleEl);
|
||||||
|
if (!isHeader) {
|
||||||
|
// Order after the title: key, NOW, type badge, arrangement.
|
||||||
|
if (on('showKey') && item.keyName) {
|
||||||
|
const key = document.createElement('span');
|
||||||
|
key.className = 'pp-key';
|
||||||
|
key.textContent = item.keyName;
|
||||||
|
titleLine.appendChild(key);
|
||||||
|
}
|
||||||
|
if (on('showNowIndicator')) {
|
||||||
|
const nowTag = document.createElement('span');
|
||||||
|
nowTag.className = 'pp-now-tag';
|
||||||
|
nowTag.textContent = 'Now';
|
||||||
|
titleLine.appendChild(nowTag);
|
||||||
|
}
|
||||||
|
if (on('showItemType') && typeLabel(item.itemType)) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `pp-badge is-${item.itemType}`;
|
||||||
|
badge.textContent = typeLabel(item.itemType);
|
||||||
|
titleLine.appendChild(badge);
|
||||||
|
}
|
||||||
|
if (on('showArrangement') && item.arrangement) {
|
||||||
|
const arr = document.createElement('span');
|
||||||
|
arr.className = 'pp-arr';
|
||||||
|
arr.textContent = item.arrangement;
|
||||||
|
titleLine.appendChild(arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main.appendChild(titleLine);
|
||||||
|
|
||||||
|
if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
|
||||||
|
const author = document.createElement('div');
|
||||||
|
author.className = 'pp-author';
|
||||||
|
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
|
||||||
|
main.appendChild(author);
|
||||||
|
}
|
||||||
|
if (on('showDescription') && item.description) {
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'pp-desc';
|
||||||
|
desc.textContent = item.description;
|
||||||
|
main.appendChild(desc);
|
||||||
|
}
|
||||||
|
if (on('showNotes') && item.notes?.length) {
|
||||||
|
const notes = item.notes.filter(notesWanted);
|
||||||
|
if (notes.length) {
|
||||||
|
const notesEl = document.createElement('div');
|
||||||
|
notesEl.className = 'pp-notes';
|
||||||
|
for (const note of notes) {
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'pp-note';
|
||||||
|
if (note.category) {
|
||||||
|
const cat = document.createElement('span');
|
||||||
|
cat.className = 'pp-note-cat';
|
||||||
|
cat.textContent = note.category;
|
||||||
|
n.appendChild(cat);
|
||||||
|
}
|
||||||
|
n.appendChild(document.createTextNode(note.content));
|
||||||
|
notesEl.appendChild(n);
|
||||||
|
}
|
||||||
|
main.appendChild(notesEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
const wantRuntime = on('showItemRuntime') && state?.live?.following;
|
||||||
|
if (on('showLength') || wantRuntime) {
|
||||||
|
const lenCol = document.createElement('div');
|
||||||
|
lenCol.className = 'pp-col-len';
|
||||||
|
if (!isHeader) {
|
||||||
|
if (on('showLength')) {
|
||||||
|
const len = document.createElement('div');
|
||||||
|
len.className = 'pp-len';
|
||||||
|
len.textContent = item.length ? fmtDur(item.length) : '';
|
||||||
|
lenCol.appendChild(len);
|
||||||
|
}
|
||||||
|
if (wantRuntime) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'pp-elapsed';
|
||||||
|
lenCol.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(lenCol);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const empty = emptyMessage();
|
||||||
|
if (empty) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
lastListKey = '';
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'pp-empty';
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'pp-empty-title';
|
||||||
|
t.textContent = empty.title;
|
||||||
|
box.appendChild(t);
|
||||||
|
if (empty.hint) {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'pp-empty-hint';
|
||||||
|
h.textContent = empty.hint;
|
||||||
|
box.appendChild(h);
|
||||||
|
}
|
||||||
|
if (empty.link) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'pp-empty-link';
|
||||||
|
a.href = empty.link.href;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.textContent = empty.link.text;
|
||||||
|
box.appendChild(a);
|
||||||
|
}
|
||||||
|
listEl.appendChild(box);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild rows only when the plan content or the display settings
|
||||||
|
// changed; live highlighting and timers are applied in place.
|
||||||
|
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
|
||||||
|
if (key !== lastListKey) {
|
||||||
|
lastListKey = key;
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
const notesWanted = noteFilter();
|
||||||
|
let number = 0;
|
||||||
|
for (const item of visibleItems()) {
|
||||||
|
if (item.itemType !== 'header') number += 1;
|
||||||
|
listEl.appendChild(buildRow(item, number, notesWanted));
|
||||||
|
}
|
||||||
|
lastCurrentId = null; // force the highlight + scroll pass
|
||||||
|
}
|
||||||
|
renderLiveMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
|
function renderLiveMarks() {
|
||||||
|
const live = state?.live || {};
|
||||||
|
const now = serverNow();
|
||||||
|
const currentId = live.following ? live.currentItemId || '' : '';
|
||||||
|
const history = live.history || {};
|
||||||
|
const byId = new Map(state.items.map((it) => [it.id, it]));
|
||||||
|
// Everything before the current item counts as done, matched or not.
|
||||||
|
const doneIds = new Set();
|
||||||
|
if (currentId) {
|
||||||
|
for (const it of state.items) {
|
||||||
|
if (it.id === currentId) break;
|
||||||
|
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const showNow = on('showNowIndicator');
|
||||||
|
for (const row of listEl.querySelectorAll('.pp-row')) {
|
||||||
|
const id = row.dataset.id;
|
||||||
|
const item = byId.get(id);
|
||||||
|
row.classList.toggle('is-current', showNow && id === currentId);
|
||||||
|
row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
|
||||||
|
const elapsedEl = row.querySelector('.pp-elapsed');
|
||||||
|
if (!elapsedEl || !item) continue;
|
||||||
|
const h = history[id];
|
||||||
|
elapsedEl.className = 'pp-elapsed';
|
||||||
|
if (!h) {
|
||||||
|
elapsedEl.textContent = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
|
||||||
|
elapsedEl.textContent = fmtDur(elapsedSec);
|
||||||
|
if (item.length) {
|
||||||
|
const over = elapsedSec - item.length;
|
||||||
|
if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
|
||||||
|
else if (over > 0) elapsedEl.classList.add('is-over');
|
||||||
|
}
|
||||||
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||||
|
}
|
||||||
|
if (currentId !== lastCurrentId) {
|
||||||
|
lastCurrentId = currentId;
|
||||||
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||||
|
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||||
|
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
||||||
|
|
||||||
|
function renderFoot() {
|
||||||
|
const live = state?.live;
|
||||||
|
const show = on('showLiveFooter') && live?.following && state?.plan;
|
||||||
|
footEl.hidden = !show;
|
||||||
|
footEl.className = 'pp-foot';
|
||||||
|
if (!show) return;
|
||||||
|
if (!live.available) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = live.reason || 'ProPresenter feed unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.ppEnabled) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = 'ProPresenter module has no host configured';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.ppReachable) {
|
||||||
|
footEl.classList.add('is-down');
|
||||||
|
footEl.textContent = 'ProPresenter unreachable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!live.activeName) {
|
||||||
|
footEl.classList.add('is-muted');
|
||||||
|
footEl.textContent = 'Nothing live in ProPresenter';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (live.matched) {
|
||||||
|
footEl.classList.add('is-live');
|
||||||
|
footEl.textContent = `Live: ${live.activeName}`;
|
||||||
|
} else {
|
||||||
|
footEl.classList.add('is-unmatched');
|
||||||
|
footEl.textContent = `Live: ${live.activeName} — not in the plan`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render + ticking ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
||||||
|
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
||||||
|
const color = (value, fallback) => {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
return /^[#a-zA-Z0-9(),.%\s-]+$/.test(s) && s ? s : fallback;
|
||||||
|
};
|
||||||
|
wrap.style.setProperty('--pp-key', color(cfg().keyColor, '#4ea1ff'));
|
||||||
|
wrap.style.setProperty('--pp-header', color(cfg().headerColor, '#2ee59a'));
|
||||||
|
wrap.style.setProperty('--pp-current', color(cfg().currentColor, '#2ee59a'));
|
||||||
|
renderStatus();
|
||||||
|
if (!state) {
|
||||||
|
headEl.hidden = true;
|
||||||
|
renderList();
|
||||||
|
footEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHead();
|
||||||
|
renderList();
|
||||||
|
renderFoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrefs() {
|
||||||
|
const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
|
||||||
|
wrap.style.setProperty('--pp-size', `${size}px`);
|
||||||
|
lastListKey = '';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!state?.plan) return;
|
||||||
|
renderClock();
|
||||||
|
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function accept(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||||
|
state = payload.state;
|
||||||
|
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
feedOffline = false;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
feedOffline = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try {
|
||||||
|
accept(JSON.parse(ev.data));
|
||||||
|
} catch { /* a bad frame is not fatal */ }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserScroll() {
|
||||||
|
userScrolledAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
applyPrefs();
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => { if (body && !state) accept(body); })
|
||||||
|
.catch(() => { /* the stream will cover it */ });
|
||||||
|
connect();
|
||||||
|
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
||||||
|
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
||||||
|
ticker = setInterval(tick, 1000);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
listEl.removeEventListener('wheel', onUserScroll);
|
||||||
|
listEl.removeEventListener('touchmove', onUserScroll);
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
onConfigChange() {
|
||||||
|
// Admin holds only connection settings; the server re-inits and its
|
||||||
|
// stream re-sends the state. Nothing cached here to refresh.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"id": "pco-plan",
|
||||||
|
"name": "PCO Plan",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"proddash": ">=1.3.0",
|
||||||
|
"description": "Order of service from Planning Center Services, following ProPresenter live",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 3, "h": 3 },
|
||||||
|
"defaultSize": { "w": 5, "h": 6 },
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"appId": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Application ID",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Secret",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"serviceTypeId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service type",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/service-types",
|
||||||
|
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
|
||||||
|
},
|
||||||
|
"planId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Plan",
|
||||||
|
"default": "",
|
||||||
|
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||||
|
"optionsRoute": "/plans",
|
||||||
|
"optionsDependsOn": "serviceTypeId",
|
||||||
|
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
||||||
|
},
|
||||||
|
"pollSeconds": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Re-read the plan every N seconds",
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"followProPresenter": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Top bar": {
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
"Colors": {
|
||||||
|
"collapsed": false,
|
||||||
|
"columns": 2
|
||||||
|
},
|
||||||
|
"Show / hide": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 3,
|
||||||
|
"help": "Untick anything this tile should not show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
||||||
|
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||||
|
"noteCategories": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Item note categories to show (comma-separated, blank = all)",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"headerTemplate": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Top bar text",
|
||||||
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
|
"group": "Top bar",
|
||||||
|
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
|
||||||
|
},
|
||||||
|
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||||
|
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||||
|
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
||||||
|
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
||||||
|
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
||||||
|
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||||
|
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||||
|
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
||||||
|
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
||||||
|
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
||||||
|
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
||||||
|
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
||||||
|
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
||||||
|
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
||||||
|
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
||||||
|
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
|
||||||
|
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||||
|
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||||
|
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||||
|
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
|
||||||
|
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
||||||
|
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
||||||
|
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCO Plan — server part.
|
||||||
|
*
|
||||||
|
* Owns the one Planning Center Services connection, keeps the current plan
|
||||||
|
* (order of service) for the configured service type in memory, and fans it
|
||||||
|
* out to every tile over SSE. Browsers never talk to Planning Center.
|
||||||
|
*
|
||||||
|
* GET /state → { state, now } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
* POST /refresh → re-read the plan now
|
||||||
|
* GET /service-types → { options } for the admin "Service type" select,
|
||||||
|
* grouped by Planning Center folder path
|
||||||
|
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||||
|
*
|
||||||
|
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||||||
|
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
||||||
|
* bearer token. Responses are JSON:API — `data`, `included`, `links.next`.
|
||||||
|
* GET /folders, /service_types (setup browsing)
|
||||||
|
* GET /service_types/{st}/plans?filter=future|past (pick the plan)
|
||||||
|
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||||
|
* GET …/plans/{id}/plan_times (service start times)
|
||||||
|
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||||
|
*
|
||||||
|
* Phase 2 — following ProPresenter: instead of talking to ProPresenter a
|
||||||
|
* second time, this module reads the propresenter-now-next module's own SSE
|
||||||
|
* feed over loopback (/api/modules/propresenter-now-next/stream) and matches
|
||||||
|
* the live presentation name to a plan item. Item start/end timestamps are
|
||||||
|
* tracked here, once, so every tile agrees on the runtime. The shell's port
|
||||||
|
* is guessed the way the shell decides it and then confirmed from the Host
|
||||||
|
* header of the first tile request.
|
||||||
|
*
|
||||||
|
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
|
||||||
|
* tools/pco-mock.js for development.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||||
|
const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
|
const SERVICES = '/services/v2';
|
||||||
|
|
||||||
|
/** Abort a hung Planning Center request well before the poll cadence. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
|
||||||
|
const MIN_POLL_S = 10;
|
||||||
|
/** Backoff after a failed plan read. */
|
||||||
|
const RETRY_MS = 15000;
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
/** The Now/Next module whose feed we follow. */
|
||||||
|
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||||
|
const NOW_NEXT_RETRY_MS = 3000;
|
||||||
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
|
/** Minimum name-similarity to call a ProPresenter document a plan item. */
|
||||||
|
const MATCH_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function clearedState() {
|
||||||
|
return {
|
||||||
|
configured: false, // credentials present
|
||||||
|
serviceTypeSet: false, // a service type is chosen
|
||||||
|
reachable: false, // last Planning Center read succeeded
|
||||||
|
lastError: '',
|
||||||
|
lastFetched: 0,
|
||||||
|
apiBase: API_BASE,
|
||||||
|
serviceType: { id: '', name: '' },
|
||||||
|
plan: null,
|
||||||
|
items: [],
|
||||||
|
live: clearedLive(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearedLive() {
|
||||||
|
return {
|
||||||
|
following: false, // admin switch on
|
||||||
|
available: false, // Now/Next feed reachable
|
||||||
|
reason: '', // why not available
|
||||||
|
ppEnabled: false,
|
||||||
|
ppReachable: false,
|
||||||
|
activeName: '', // what ProPresenter has live right now
|
||||||
|
matched: false, // activeName maps to a plan item
|
||||||
|
currentItemId: '',
|
||||||
|
currentScore: 0,
|
||||||
|
serviceStartedAt: 0, // first matched item start, ms epoch
|
||||||
|
history: {}, // itemId → { startedAt, endedAt }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First guess at the shell's port, so the loopback Now/Next feed lands on this
|
||||||
|
* ProdDash before any browser has talked to us. Mirrors how the shell decides:
|
||||||
|
* PORT env, then this machine's proddash.json in the data directory, then the
|
||||||
|
* checked-in config/proddash.json, then 24500. The guess is corrected from the
|
||||||
|
* Host header of the first request that reaches a route (see learnPort).
|
||||||
|
*/
|
||||||
|
function guessShellPort() {
|
||||||
|
const env = Number(process.env.PORT);
|
||||||
|
if (env) return env;
|
||||||
|
const home = os.homedir();
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const dataDir = fromEnv
|
||||||
|
? path.resolve(root, fromEnv)
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? path.join(home, 'Library', 'Application Support', 'ProdDash')
|
||||||
|
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
|
||||||
|
try {
|
||||||
|
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
|
||||||
|
if (port) return port;
|
||||||
|
} catch { /* try the next one */ }
|
||||||
|
}
|
||||||
|
return 24500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
|
||||||
|
function portFromHost(hostHeader) {
|
||||||
|
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
|
||||||
|
if (m) return Number(m[1]) || 0;
|
||||||
|
return hostHeader ? 80 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localYmd(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
|
||||||
|
function cleanTitle(raw) {
|
||||||
|
const original = String(raw || '').trim();
|
||||||
|
let s = original;
|
||||||
|
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
|
||||||
|
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
|
||||||
|
s = s.replace(/\s[-–—]+\s/g, ' ');
|
||||||
|
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
|
||||||
|
s = s.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return s || original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw) {
|
||||||
|
return cleanTitle(raw)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[’'`]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
|
||||||
|
function tokens(norm) {
|
||||||
|
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0..1 similarity between two titles. */
|
||||||
|
function similarity(a, b) {
|
||||||
|
const na = normalize(a);
|
||||||
|
const nb = normalize(b);
|
||||||
|
if (!na || !nb) return 0;
|
||||||
|
if (na === nb) return 1;
|
||||||
|
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
|
||||||
|
const ta = tokens(na);
|
||||||
|
const tb = tokens(nb);
|
||||||
|
if (!ta.size || !tb.size) return 0;
|
||||||
|
let common = 0;
|
||||||
|
for (const t of ta) if (tb.has(t)) common += 1;
|
||||||
|
return (2 * common) / (ta.size + tb.size); // Dice coefficient
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Planning Center client ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function createPcoClient(config) {
|
||||||
|
const token = String(config.accessToken || '').trim();
|
||||||
|
const appId = String(config.appId || '').trim();
|
||||||
|
const secret = String(config.secret || '');
|
||||||
|
let auth = '';
|
||||||
|
if (token) auth = `Bearer ${token}`;
|
||||||
|
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||||||
|
|
||||||
|
async function get(target, params) {
|
||||||
|
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||||||
|
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers: { Authorization: auth, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
|
||||||
|
e.network = true;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
|
||||||
|
} catch { /* no JSON body */ }
|
||||||
|
const e = new Error(
|
||||||
|
response.status === 401
|
||||||
|
? 'Planning Center rejected the credentials (401)'
|
||||||
|
: `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
|
||||||
|
);
|
||||||
|
e.status = response.status;
|
||||||
|
if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follow JSON:API `links.next` until the collection is complete. */
|
||||||
|
async function getAll(target, params) {
|
||||||
|
const data = [];
|
||||||
|
const included = [];
|
||||||
|
let url = target;
|
||||||
|
let query = { per_page: 100, ...(params || {}) };
|
||||||
|
for (let page = 0; page < 30; page += 1) {
|
||||||
|
const body = await get(url, query);
|
||||||
|
if (Array.isArray(body?.data)) data.push(...body.data);
|
||||||
|
if (Array.isArray(body?.included)) included.push(...body.included);
|
||||||
|
const next = body?.links?.next;
|
||||||
|
if (!next) break;
|
||||||
|
url = next;
|
||||||
|
query = {}; // `next` already carries the query string
|
||||||
|
}
|
||||||
|
return { data, included };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── plan shaping ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function pickServiceTime(planTimes, nowMs) {
|
||||||
|
const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt);
|
||||||
|
const pool = services.length ? services : planTimes.filter((t) => t.startsAt);
|
||||||
|
if (!pool.length) return null;
|
||||||
|
pool.sort((a, b) => a.startsAt - b.startsAt);
|
||||||
|
// The service that is running or up next; after the last one, the last one.
|
||||||
|
const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs);
|
||||||
|
return upcoming || pool[pool.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled start of every item from the chosen service start time. */
|
||||||
|
function scheduleItems(items, serviceStartMs) {
|
||||||
|
for (const item of items) item.startsAt = 0;
|
||||||
|
if (!serviceStartMs) return;
|
||||||
|
let t = serviceStartMs;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.servicePosition !== 'during') continue;
|
||||||
|
item.startsAt = t;
|
||||||
|
t += (item.length || 0) * 1000;
|
||||||
|
}
|
||||||
|
// Post-service items run on from the end of the service…
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.servicePosition !== 'post') continue;
|
||||||
|
item.startsAt = t;
|
||||||
|
t += (item.length || 0) * 1000;
|
||||||
|
}
|
||||||
|
// …and pre-service items are counted backwards to end at the service start.
|
||||||
|
let back = serviceStartMs;
|
||||||
|
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.servicePosition !== 'pre') continue;
|
||||||
|
back -= (item.length || 0) * 1000;
|
||||||
|
item.startsAt = back;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeItems(itemsBody) {
|
||||||
|
const included = new Map();
|
||||||
|
for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||||
|
const rel = (item, name) => item?.relationships?.[name]?.data;
|
||||||
|
const items = (itemsBody.data || []).map((it) => {
|
||||||
|
const a = it.attributes || {};
|
||||||
|
const songRef = rel(it, 'song');
|
||||||
|
const song = songRef ? included.get(`Song/${songRef.id}`) : null;
|
||||||
|
const arrRef = rel(it, 'arrangement');
|
||||||
|
const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
|
||||||
|
const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
|
||||||
|
const notes = noteRefs
|
||||||
|
.map((ref) => included.get(`ItemNote/${ref.id}`))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((n) => ({
|
||||||
|
category: String(n.attributes?.category_name || ''),
|
||||||
|
content: String(n.attributes?.content || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((n) => n.content);
|
||||||
|
return {
|
||||||
|
id: String(it.id),
|
||||||
|
sequence: Number(a.sequence) || 0,
|
||||||
|
title: String(a.title || '').trim(),
|
||||||
|
itemType: String(a.item_type || 'item'),
|
||||||
|
length: Number(a.length) || 0,
|
||||||
|
servicePosition: String(a.service_position || 'during'),
|
||||||
|
description: String(a.description || '').trim(),
|
||||||
|
keyName: String(a.key_name || '').trim(),
|
||||||
|
song: song
|
||||||
|
? {
|
||||||
|
title: String(song.attributes?.title || ''),
|
||||||
|
author: String(song.attributes?.author || ''),
|
||||||
|
ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
|
||||||
|
notes,
|
||||||
|
startsAt: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
items.sort((x, y) => x.sequence - y.sequence);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapePlan(planRec, serviceTypeId, planTimes) {
|
||||||
|
const a = planRec?.attributes || {};
|
||||||
|
return {
|
||||||
|
id: String(planRec?.id || ''),
|
||||||
|
serviceTypeId: String(serviceTypeId),
|
||||||
|
title: String(a.title || '').trim(),
|
||||||
|
seriesTitle: String(a.series_title || '').trim(),
|
||||||
|
dates: String(a.dates || '').trim(),
|
||||||
|
shortDates: String(a.short_dates || '').trim(),
|
||||||
|
sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
|
||||||
|
totalLength: Number(a.total_length) || 0,
|
||||||
|
itemsCount: Number(a.items_count) || 0,
|
||||||
|
url: String(a.planning_center_url || ''),
|
||||||
|
planTimes,
|
||||||
|
serviceStartsAt: 0,
|
||||||
|
serviceEndsAt: 0,
|
||||||
|
serviceName: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the module ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const pco = createPcoClient(config);
|
||||||
|
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||||
|
const pinnedPlanId = String(config.planId || '').trim();
|
||||||
|
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||||||
|
const follow = config.followProPresenter !== false;
|
||||||
|
|
||||||
|
let state = clearedState();
|
||||||
|
state.configured = pco.configured;
|
||||||
|
state.serviceTypeSet = Boolean(serviceTypeId);
|
||||||
|
state.serviceType = { id: serviceTypeId, name: '' };
|
||||||
|
state.live.following = follow;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let pollTimer = null;
|
||||||
|
let inFlight = null;
|
||||||
|
let lastFrame = '';
|
||||||
|
|
||||||
|
/* — broadcasting — */
|
||||||
|
|
||||||
|
function frameOf() {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(force) {
|
||||||
|
const frame = frameOf();
|
||||||
|
if (!force && frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
if (!streams.size) return;
|
||||||
|
const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — plan reading — */
|
||||||
|
|
||||||
|
function planPath(planId) {
|
||||||
|
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||||||
|
async function listPlans(stId, { future = 5, past = 3 } = {}) {
|
||||||
|
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||||||
|
const [fut, pst] = await Promise.all([
|
||||||
|
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
|
||||||
|
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
|
||||||
|
]);
|
||||||
|
const byId = new Map();
|
||||||
|
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
|
||||||
|
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||||||
|
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||||||
|
byId.set(String(rec.id), { rec, sortDate });
|
||||||
|
}
|
||||||
|
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||||||
|
function pickPlan(plans) {
|
||||||
|
if (!plans.length) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const today = localYmd(new Date(now));
|
||||||
|
const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
|
||||||
|
if (todays) return todays.rec;
|
||||||
|
const next = plans.find((p) => p.sortDate > now);
|
||||||
|
if (next) return next.rec;
|
||||||
|
return plans[plans.length - 1].rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePlanId(stId) {
|
||||||
|
return pickPlan(await listPlans(stId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlanTimes(planId) {
|
||||||
|
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||||||
|
return (body.data || [])
|
||||||
|
.map((t) => ({
|
||||||
|
id: String(t.id),
|
||||||
|
name: String(t.attributes?.name || '').trim(),
|
||||||
|
timeType: String(t.attributes?.time_type || 'service'),
|
||||||
|
startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
|
||||||
|
endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
|
||||||
|
}))
|
||||||
|
.filter((t) => t.startsAt)
|
||||||
|
.sort((x, y) => x.startsAt - y.startsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readItems(planId) {
|
||||||
|
const target = `${planPath(planId)}/items`;
|
||||||
|
try {
|
||||||
|
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||||||
|
} catch (err) {
|
||||||
|
// An account/API that refuses one of the includes still has items.
|
||||||
|
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||||||
|
log(`items include rejected (${err.message}) — reading items without includes`);
|
||||||
|
return shapeItems(await pco.getAll(target));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlan() {
|
||||||
|
if (!pco.configured || !serviceTypeId) return;
|
||||||
|
let planRec = null;
|
||||||
|
if (pinnedPlanId) {
|
||||||
|
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
||||||
|
} else {
|
||||||
|
const chosen = await choosePlanId(serviceTypeId);
|
||||||
|
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
||||||
|
// same, but this keeps one code path and picks up edits immediately.
|
||||||
|
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
||||||
|
}
|
||||||
|
if (!state.serviceType.name) {
|
||||||
|
try {
|
||||||
|
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
||||||
|
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!planRec) {
|
||||||
|
state.plan = null;
|
||||||
|
state.items = [];
|
||||||
|
state.reachable = true;
|
||||||
|
state.lastError = '';
|
||||||
|
state.lastFetched = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||||||
|
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||||||
|
const service = pickServiceTime(planTimes, Date.now());
|
||||||
|
if (service) {
|
||||||
|
plan.serviceStartsAt = service.startsAt;
|
||||||
|
plan.serviceEndsAt = service.endsAt;
|
||||||
|
plan.serviceName = service.name;
|
||||||
|
}
|
||||||
|
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||||||
|
if (state.plan?.id !== plan.id) {
|
||||||
|
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`);
|
||||||
|
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
|
||||||
|
resetLiveTracking();
|
||||||
|
}
|
||||||
|
state.plan = plan;
|
||||||
|
state.items = items;
|
||||||
|
state.reachable = true;
|
||||||
|
state.lastError = '';
|
||||||
|
state.lastFetched = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(ms) {
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = setTimeout(poll, ms);
|
||||||
|
pollTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
let delay = pollMs;
|
||||||
|
try {
|
||||||
|
await readPlan();
|
||||||
|
} catch (err) {
|
||||||
|
state.reachable = false;
|
||||||
|
state.lastError = err?.message || String(err);
|
||||||
|
delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
|
||||||
|
log(`plan read failed: ${state.lastError}`);
|
||||||
|
}
|
||||||
|
broadcast();
|
||||||
|
schedulePoll(delay);
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await inFlight;
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
|
||||||
|
|
||||||
|
let nnRequest = null;
|
||||||
|
let nnRetryTimer = null;
|
||||||
|
let lastLiveName = null;
|
||||||
|
const guessedPort = guessShellPort();
|
||||||
|
let nnPort = guessedPort;
|
||||||
|
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
|
||||||
|
const badPorts = new Set();
|
||||||
|
|
||||||
|
/** A browser reached us on this Host — normally the port the shell really serves on. */
|
||||||
|
function learnPort(hostHeader) {
|
||||||
|
const port = portFromHost(hostHeader);
|
||||||
|
if (!port || port === nnPort || badPorts.has(port)) return;
|
||||||
|
log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`);
|
||||||
|
nnPort = port;
|
||||||
|
if (!follow || stopped) return;
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
nnRequest = null;
|
||||||
|
connectNowNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLiveTracking() {
|
||||||
|
const keep = state.live;
|
||||||
|
state.live = {
|
||||||
|
...clearedLive(),
|
||||||
|
following: keep.following,
|
||||||
|
available: keep.available,
|
||||||
|
reason: keep.reason,
|
||||||
|
ppEnabled: keep.ppEnabled,
|
||||||
|
ppReachable: keep.ppReachable,
|
||||||
|
activeName: keep.activeName,
|
||||||
|
};
|
||||||
|
lastLiveName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
||||||
|
function matchItem(name) {
|
||||||
|
const items = state.items.filter((it) => it.itemType !== 'header');
|
||||||
|
if (!items.length) return null;
|
||||||
|
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||||
|
if (score < MATCH_THRESHOLD) continue;
|
||||||
|
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||||
|
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||||
|
if (
|
||||||
|
!best
|
||||||
|
|| score > best.score + 0.001
|
||||||
|
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
||||||
|
) {
|
||||||
|
best = { item, score, dist };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLiveView(view) {
|
||||||
|
const live = state.live;
|
||||||
|
live.available = true;
|
||||||
|
live.reason = '';
|
||||||
|
live.ppEnabled = Boolean(view?.enabled);
|
||||||
|
live.ppReachable = Boolean(view?.reachable);
|
||||||
|
const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
|
||||||
|
const name = String(activeItem?.name || view?.activePresentationName || '').trim();
|
||||||
|
live.activeName = name;
|
||||||
|
if (!name) {
|
||||||
|
// Cleared output between items is normal — the current item stays current.
|
||||||
|
lastLiveName = '';
|
||||||
|
broadcast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === lastLiveName) return;
|
||||||
|
lastLiveName = name;
|
||||||
|
const hit = matchItem(name);
|
||||||
|
live.matched = Boolean(hit);
|
||||||
|
if (hit) {
|
||||||
|
live.currentScore = hit.score;
|
||||||
|
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
||||||
|
}
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startItem(itemId) {
|
||||||
|
const live = state.live;
|
||||||
|
const now = Date.now();
|
||||||
|
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||||
|
live.history[live.currentItemId].endedAt = now;
|
||||||
|
}
|
||||||
|
live.currentItemId = itemId;
|
||||||
|
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||||
|
if (!live.serviceStartedAt) live.serviceStartedAt = now;
|
||||||
|
const item = state.items.find((it) => it.id === itemId);
|
||||||
|
log(`live item → ${item?.title || itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowNextUnavailable(reason, retryMs) {
|
||||||
|
const live = state.live;
|
||||||
|
if (live.available || live.reason !== reason) {
|
||||||
|
live.available = false;
|
||||||
|
live.reason = reason;
|
||||||
|
live.ppReachable = false;
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
nnRetryTimer = setTimeout(connectNowNext, retryMs);
|
||||||
|
nnRetryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNowNext() {
|
||||||
|
if (stopped || !follow) return;
|
||||||
|
const port = nnPort;
|
||||||
|
const req = http.get(
|
||||||
|
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||||
|
(res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
const why = res.statusCode === 404
|
||||||
|
? 'ProPresenter module is disabled or not installed'
|
||||||
|
: `ProPresenter module feed answered ${res.statusCode}`;
|
||||||
|
return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
|
||||||
|
}
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
let buffer = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let idx;
|
||||||
|
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const block = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (event !== 'state' || !data) continue;
|
||||||
|
try {
|
||||||
|
applyLiveView(JSON.parse(data));
|
||||||
|
} catch { /* a bad frame is not fatal */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
|
||||||
|
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
|
||||||
|
if (port !== guessedPort) {
|
||||||
|
// The Host header lied (reverse proxy, port forward) — that port is
|
||||||
|
// not this ProdDash. Go back to the guess and never trust it again.
|
||||||
|
badPorts.add(port);
|
||||||
|
nnPort = guessedPort;
|
||||||
|
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
|
||||||
|
}
|
||||||
|
nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS);
|
||||||
|
});
|
||||||
|
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||||
|
nnRequest = req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — start — */
|
||||||
|
|
||||||
|
if (!pco.configured) {
|
||||||
|
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
||||||
|
log(state.lastError);
|
||||||
|
} else if (!serviceTypeId) {
|
||||||
|
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||||||
|
log(state.lastError);
|
||||||
|
} else {
|
||||||
|
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||||
|
schedulePoll(0);
|
||||||
|
}
|
||||||
|
if (follow) {
|
||||||
|
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||||
|
connectNowNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = {
|
||||||
|
streams,
|
||||||
|
pco,
|
||||||
|
getState: () => state,
|
||||||
|
learnPort,
|
||||||
|
refresh() {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
return poll();
|
||||||
|
},
|
||||||
|
listPlans,
|
||||||
|
pickPlan,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
clearTimeout(nnRetryTimer);
|
||||||
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||||
|
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
|
||||||
|
if (!state.reachable) {
|
||||||
|
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||||
|
}
|
||||||
|
const name = state.serviceType.name || serviceTypeId;
|
||||||
|
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||||||
|
const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||||||
|
return { status: 'ok', message: `Following ${name}${plan}${live}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes({ log }) {
|
||||||
|
const json = (res, status, body) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
current?.learnPort(req.headers.host);
|
||||||
|
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
current?.learnPort(req.headers.host);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
const state = current ? current.getState() : clearedState();
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /refresh': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
current.refresh().then(
|
||||||
|
() => json(res, 200, { ok: true, state: current?.getState() }),
|
||||||
|
(err) => json(res, 502, { error: err?.message || String(err) })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the admin page's "Service type" select: every service
|
||||||
|
* type, grouped by its Planning Center folder path — so the folders
|
||||||
|
* are browsable as <optgroup>s right in the admin form.
|
||||||
|
*/
|
||||||
|
'GET /service-types': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const { pco } = current;
|
||||||
|
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||||
|
try {
|
||||||
|
const [folderBody, stBody] = await Promise.all([
|
||||||
|
pco.getAll(`${SERVICES}/folders`),
|
||||||
|
pco.getAll(`${SERVICES}/service_types`),
|
||||||
|
]);
|
||||||
|
const folders = new Map();
|
||||||
|
for (const f of folderBody.data || []) {
|
||||||
|
folders.set(String(f.id), {
|
||||||
|
id: String(f.id),
|
||||||
|
name: String(f.attributes?.name || 'Folder'),
|
||||||
|
parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const serviceTypes = (stBody.data || []).map((st) => ({
|
||||||
|
id: String(st.id),
|
||||||
|
name: String(st.attributes?.name || 'Service type'),
|
||||||
|
frequency: String(st.attributes?.frequency || ''),
|
||||||
|
parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
|
||||||
|
}));
|
||||||
|
// Fill in any folder the list did not include (some accounts only
|
||||||
|
// list root folders) by fetching the missing parents individually.
|
||||||
|
const missing = new Set();
|
||||||
|
for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
|
||||||
|
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
|
||||||
|
let guard = 0;
|
||||||
|
while (missing.size && guard < 50) {
|
||||||
|
const id = missing.values().next().value;
|
||||||
|
missing.delete(id);
|
||||||
|
guard += 1;
|
||||||
|
try {
|
||||||
|
const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
|
||||||
|
const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
|
||||||
|
folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
|
||||||
|
if (parentId && !folders.has(parentId)) missing.add(parentId);
|
||||||
|
} catch {
|
||||||
|
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Campuses › North Campus" — the folder path from the root down.
|
||||||
|
const pathOf = (parentId) => {
|
||||||
|
const names = [];
|
||||||
|
let id = parentId;
|
||||||
|
const seen = new Set();
|
||||||
|
while (id && folders.has(id) && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
names.unshift(folders.get(id).name);
|
||||||
|
id = folders.get(id).parentId;
|
||||||
|
}
|
||||||
|
return names.join(' › ');
|
||||||
|
};
|
||||||
|
const options = serviceTypes
|
||||||
|
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
||||||
|
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||||
|
json(res, 200, { options });
|
||||||
|
} catch (err) {
|
||||||
|
log(`service type list failed: ${err?.message || err}`);
|
||||||
|
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the admin page's "Plan" select, for the service type the
|
||||||
|
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
||||||
|
* plans, with the one Automatic would choose marked.
|
||||||
|
*/
|
||||||
|
'GET /plans': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const { pco } = current;
|
||||||
|
const params = new URLSearchParams(req.search || '');
|
||||||
|
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||||
|
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||||
|
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||||
|
try {
|
||||||
|
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
||||||
|
const picked = current.pickPlan(plans);
|
||||||
|
const now = Date.now();
|
||||||
|
const options = [auto];
|
||||||
|
// Upcoming first (soonest at the top), then the recent ones.
|
||||||
|
const ordered = [
|
||||||
|
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||||
|
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||||
|
];
|
||||||
|
for (const { rec, sortDate } of ordered) {
|
||||||
|
const a = rec.attributes || {};
|
||||||
|
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
||||||
|
if (a.series_title) bits.push(String(a.series_title));
|
||||||
|
if (a.title) bits.push(String(a.title));
|
||||||
|
let label = bits.join(' — ');
|
||||||
|
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
||||||
|
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
||||||
|
}
|
||||||
|
json(res, 200, { options });
|
||||||
|
} catch (err) {
|
||||||
|
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/* PCO Plan module — scoped to its tiles, shell variables only (plus the
|
||||||
|
admin-chosen --pp-key color for song keys). Everything scales from
|
||||||
|
--pp-size (set from the tile's text-size setting). */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-wrap {
|
||||||
|
--pp-size: 14px;
|
||||||
|
--pp-key: #4ea1ff;
|
||||||
|
--pp-header: var(--accent);
|
||||||
|
--pp-current: var(--accent);
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: var(--pp-size);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── top bar ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1em;
|
||||||
|
padding: 0.6em 0.85em 0.5em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-head[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-plan-sub {
|
||||||
|
margin-top: 0.15em;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-clock {
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-label {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-value {
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-clock-value:empty,
|
||||||
|
[data-module="pco-plan"] .pp-clock-label:empty { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-clock-value.is-behind { color: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-clock-value.is-ahead { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── list ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.35em 0.5em 0.6em;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6em;
|
||||||
|
padding: 0.45em 0.5em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.2s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
|
||||||
|
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||||
|
|
||||||
|
/* Optional background tint per item type (admin "Background color per item type"). */
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||||
|
|
||||||
|
/* Section headers: a band that runs edge to edge across the tile (negative
|
||||||
|
margins cancel the list padding) so a new part of the service is
|
||||||
|
unmistakable — tile-chosen color, solid or fading to the right. The
|
||||||
|
left padding keeps the text aligned with the other rows' text. */
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header {
|
||||||
|
margin: 0.9em -0.5em 0;
|
||||||
|
padding: 0.5em 1em 0.45em calc(1em + 3px);
|
||||||
|
align-items: center;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in srgb, var(--pp-header) 22%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; }
|
||||||
|
[data-module="pco-plan"] .pp-row + .pp-row.is-header { margin-top: 0.9em; }
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header:hover { background: color-mix(in srgb, var(--pp-header) 28%, transparent); }
|
||||||
|
[data-module="pco-plan"] .header-gradient .pp-row.is-header {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 30%, transparent), color-mix(in srgb, var(--pp-header) 6%, transparent) 70%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .header-gradient .pp-row.is-header:hover {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 36%, transparent), color-mix(in srgb, var(--pp-header) 8%, transparent) 70%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-title {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-time,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-num,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-done { opacity: 0.45; }
|
||||||
|
[data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current {
|
||||||
|
border-left-color: var(--pp-current);
|
||||||
|
background: color-mix(in srgb, var(--pp-current) 16%, transparent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-num {
|
||||||
|
flex: none;
|
||||||
|
width: 1.6em;
|
||||||
|
padding-top: 0.1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-time {
|
||||||
|
flex: none;
|
||||||
|
width: 4.6em;
|
||||||
|
padding-top: 0.1em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--pp-current); font-weight: 700; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-title-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2em 0.55em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The song key: right after the title, larger and in the admin-chosen
|
||||||
|
color (blue by default) so the band can read it from across the booth. */
|
||||||
|
[data-module="pco-plan"] .pp-key {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
color: var(--pp-key);
|
||||||
|
background: color-mix(in srgb, var(--pp-key) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--pp-key) 45%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
|
display: none;
|
||||||
|
padding: 0.05em 0.45em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pp-current);
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 0.65em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-badge,
|
||||||
|
[data-module="pco-plan"] .pp-arr {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-badge {
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-author {
|
||||||
|
margin-top: 0.1em;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-desc {
|
||||||
|
margin-top: 0.15em;
|
||||||
|
font-size: 0.86em;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: color-mix(in srgb, var(--text) 80%, var(--muted));
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-notes {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-note {
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--warn) 60%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-note-cat {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-len {
|
||||||
|
flex: none;
|
||||||
|
min-width: 3.2em;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-len {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-top: 0.1em;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-elapsed {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-elapsed:empty { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-running { color: var(--accent); opacity: 1; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-over { color: var(--warn); opacity: 1; }
|
||||||
|
[data-module="pco-plan"] .pp-elapsed.is-over-hard { color: var(--danger); opacity: 1; }
|
||||||
|
|
||||||
|
/* ── empty / degraded states ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-empty {
|
||||||
|
padding: 12% 1.2em 1em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-hint {
|
||||||
|
margin-top: 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.9em;
|
||||||
|
padding: 0.35em 0.9em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-wrap.is-offline .pp-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-wrap.is-offline .pp-list { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ── footer: ProPresenter match line ── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-foot {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
padding: 0.4em 0.85em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-foot[hidden] { display: none; }
|
||||||
|
[data-module="pco-plan"] .pp-foot::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.55em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-live { color: var(--text); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-live::before { background: var(--accent); box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-unmatched { color: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
||||||
|
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
||||||
@@ -296,11 +296,17 @@ export default function create({ root, moduleApi }) {
|
|||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.className = 'text';
|
text.className = 'text';
|
||||||
|
|
||||||
|
// channel name with its timestamp to the right; the clock button
|
||||||
|
// only toggles the timestamp's visibility
|
||||||
|
const chLine = document.createElement('span');
|
||||||
|
chLine.className = 'channel-line';
|
||||||
|
chLine.append(ch, time);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'body';
|
body.className = 'body';
|
||||||
body.append(ch, text);
|
body.append(chLine, text);
|
||||||
|
|
||||||
el.append(time, avatar, body);
|
el.append(avatar, body);
|
||||||
entryEls.set(id, el);
|
entryEls.set(id, el);
|
||||||
applyFilter(el);
|
applyFilter(el);
|
||||||
entriesEl.appendChild(el);
|
entriesEl.appendChild(el);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript stream",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,10 +10,10 @@
|
|||||||
"minSize": { "w": 3, "h": 2 },
|
"minSize": { "w": 3, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": {
|
"prodcom": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProdCom URL",
|
"label": "ProdCom server",
|
||||||
"default": "http://10.3.11.152:24480"
|
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
|
|||||||
req.pipe(preq);
|
req.pipe(preq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||||
|
before the endpoint field existed) can only still be present until the
|
||||||
|
admin form is re-saved, so when it exists it wins over the endpoint's
|
||||||
|
schema default. */
|
||||||
|
function resolveTarget(config) {
|
||||||
|
if (config.prodcomUrl) return new URL(config.prodcomUrl);
|
||||||
|
const ep = config.prodcom;
|
||||||
|
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config }) {
|
init({ config }) {
|
||||||
health = config.prodcomUrl
|
health = resolveTarget(config)
|
||||||
? { status: 'connecting', message: 'No traffic yet' }
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
: { status: 'error', message: 'No ProdCom URL configured' };
|
: { status: 'error', message: 'No ProdCom server configured' };
|
||||||
return {
|
return {
|
||||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
health: () => health,
|
health: () => health,
|
||||||
@@ -83,8 +94,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log }) {
|
||||||
// An invalid URL throws here, which surfaces as a mount error in /admin.
|
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||||
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
|
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||||
log(`proxying /prodcom/* → ${target.origin}`);
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
return {
|
return {
|
||||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
|
|||||||
@@ -82,15 +82,24 @@
|
|||||||
to { opacity: 1; transform: none; }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .channel-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* timestamp sits to the right of the channel name; the clock button only
|
||||||
|
toggles its visibility */
|
||||||
[data-module="prodcom-transcript"] .pt-entry .time {
|
[data-module="prodcom-transcript"] .pt-entry .time {
|
||||||
display: none;
|
display: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding-top: 3px;
|
|
||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; }
|
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: inline; }
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -114,7 +123,9 @@
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-bottom: 4px;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry .text {
|
[data-module="prodcom-transcript"] .pt-entry .text {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.1.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,15 +10,10 @@
|
|||||||
"minSize": { "w": 3, "h": 3 },
|
"minSize": { "w": 3, "h": 3 },
|
||||||
"defaultSize": { "w": 4, "h": 5 },
|
"defaultSize": { "w": 4, "h": 5 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ let current = null;
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const enabled = Boolean(config.host && Number(config.port));
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
|
const enabled = Boolean(host && port);
|
||||||
let latest = clearedViewModel();
|
let latest = clearedViewModel();
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
|
|
||||||
@@ -54,14 +60,14 @@ module.exports = {
|
|||||||
|
|
||||||
client.configure({
|
client.configure({
|
||||||
enabled,
|
enabled,
|
||||||
host: String(config.host || ''),
|
host,
|
||||||
port: Number(config.port) || 1025,
|
port,
|
||||||
password: String(config.password || ''),
|
password: String(config.password || ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
client.start();
|
client.start();
|
||||||
log(`following ProPresenter at ${config.host}:${config.port}`);
|
log(`following ProPresenter at ${host}:${port}`);
|
||||||
} else {
|
} else {
|
||||||
log('no ProPresenter host configured — set one in /admin');
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
|
if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: latest.lastError
|
message: latest.lastError
|
||||||
|
|||||||
@@ -11,6 +11,45 @@
|
|||||||
|
|
||||||
const LTC_KEY = '__ltc__';
|
const LTC_KEY = '__ltc__';
|
||||||
|
|
||||||
|
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
|
||||||
|
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
||||||
|
counts every frame in between locally. That needs exact frame↔timecode
|
||||||
|
conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at
|
||||||
|
the top of a minute, except every tenth minute). */
|
||||||
|
|
||||||
|
export function parseTc(str) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tcToFrames(tc, nominal, df) {
|
||||||
|
let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f;
|
||||||
|
if (df) {
|
||||||
|
const mins = tc.h * 60 + tc.m;
|
||||||
|
n -= 2 * (mins - Math.floor(mins / 10));
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function framesToTc(n, nominal, df) {
|
||||||
|
// 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798).
|
||||||
|
const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal;
|
||||||
|
n = ((n % perDay) + perDay) % perDay;
|
||||||
|
if (df) {
|
||||||
|
const d = Math.floor(n / 17982);
|
||||||
|
const m10 = n % 17982;
|
||||||
|
const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1;
|
||||||
|
const totalMin = d * 10 + extra;
|
||||||
|
// Re-add the two dropped frames per dropped minute, then read as 30fps.
|
||||||
|
n += 2 * (totalMin - Math.floor(totalMin / 10));
|
||||||
|
}
|
||||||
|
const f = n % nominal;
|
||||||
|
const secs = Math.floor(n / nominal);
|
||||||
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
@@ -61,7 +100,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function menuRows() {
|
function menuRows() {
|
||||||
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
|
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
|
||||||
rows.push({ key: LTC_KEY, name: 'LTC timecode' });
|
// No LTC row while the listener is off in Admin — the item shouldn't
|
||||||
|
// even be offerable.
|
||||||
|
if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' });
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +200,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const t of state?.timers || []) {
|
for (const t of state?.timers || []) {
|
||||||
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
||||||
}
|
}
|
||||||
if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
|
if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,32 +246,117 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.el.className = `tm-card is-${kind}`;
|
card.el.className = `tm-card is-${kind}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── real-time LTC: count every frame between server anchors ─────── */
|
||||||
|
|
||||||
|
// The server anchors us ~4×/s with the decoded timecode, its rate and
|
||||||
|
// how old it is (ageMs). Between anchors a requestAnimationFrame loop
|
||||||
|
// advances the display at the LTC's own rate, so every frame paints —
|
||||||
|
// without pushing 30 SSE messages a second at every dashboard.
|
||||||
|
let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df }
|
||||||
|
let ltcRaf = 0;
|
||||||
|
let ltcShownFrames = -1;
|
||||||
|
/** Anchors arrive ~every 250 ms while running. None for this long means
|
||||||
|
the feed state is changing — hold rather than freewheel into phantom
|
||||||
|
frames the next update would visibly rewind. */
|
||||||
|
const ANCHOR_FRESH_MS = 350;
|
||||||
|
|
||||||
|
function updateLtcAnchor() {
|
||||||
|
const l = state?.ltc;
|
||||||
|
// The listener is extrapolatable — it knows the frame rate.
|
||||||
|
const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener';
|
||||||
|
const tc = live ? parseTc(l.time) : null;
|
||||||
|
if (!tc) {
|
||||||
|
ltcAnchor = null;
|
||||||
|
ltcShownFrames = -1;
|
||||||
|
if (ltcRaf) {
|
||||||
|
cancelAnimationFrame(ltcRaf);
|
||||||
|
ltcRaf = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nominal = l.df ? 30 : Math.round(l.fps);
|
||||||
|
ltcAnchor = {
|
||||||
|
frames: tcToFrames(tc, nominal, l.df),
|
||||||
|
at: performance.now() - (Number(l.ageMs) || 0),
|
||||||
|
recvAt: performance.now(),
|
||||||
|
rate: l.df ? 30000 / 1001 : nominal,
|
||||||
|
nominal,
|
||||||
|
df: l.df,
|
||||||
|
};
|
||||||
|
if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the frame the anchor implies for right now. Called both by the
|
||||||
|
rAF loop (smooth, per-frame, on visible tabs) and by render() at the
|
||||||
|
4×/s anchor rate — so a throttled/backgrounded tab, where rAF is
|
||||||
|
paused, still advances at the anchor rate instead of freezing. */
|
||||||
|
function paintLtcFrame() {
|
||||||
|
const a = ltcAnchor;
|
||||||
|
const card = cards.get(LTC_KEY);
|
||||||
|
if (!a || !card) return;
|
||||||
|
const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS);
|
||||||
|
let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate);
|
||||||
|
// Anchor jitter (±1 frame) must never tick the display backwards;
|
||||||
|
// a genuine jump back (re-strike, seek) is far larger and passes.
|
||||||
|
if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames;
|
||||||
|
if (n !== ltcShownFrames) {
|
||||||
|
ltcShownFrames = n;
|
||||||
|
card.timeEl.textContent = framesToTc(n, a.nominal, a.df);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ltcTick() {
|
||||||
|
ltcRaf = 0;
|
||||||
|
// No anchor, or the LTC card is hidden in this tile: stop — the next
|
||||||
|
// server anchor (4×/s while running) re-arms the loop via render().
|
||||||
|
if (!ltcAnchor || !cards.get(LTC_KEY)) return;
|
||||||
|
paintLtcFrame();
|
||||||
|
ltcRaf = requestAnimationFrame(ltcTick);
|
||||||
|
}
|
||||||
|
|
||||||
function updateLtcCard(card, ltc) {
|
function updateLtcCard(card, ltc) {
|
||||||
let mode;
|
let mode;
|
||||||
|
// Only the audio sources know the frame rate (decoded from the LTC
|
||||||
|
// bits); a set drop-frame flag implies 29.97 whatever fps rounds to.
|
||||||
|
const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : '';
|
||||||
if (ltc.supported === false) {
|
if (ltc.supported === false) {
|
||||||
mode = 'unavailable';
|
mode = 'unavailable';
|
||||||
card.timeEl.textContent = '—';
|
card.timeEl.textContent = '—';
|
||||||
card.stateEl.textContent = 'Timecode not available on this ProPresenter';
|
// The server says why when it knows (listener disabled, no device
|
||||||
} else if (ltc.supported === null) {
|
// selected, capture failed, starting up…) — surface that.
|
||||||
mode = 'waiting';
|
card.stateEl.textContent = ltc.note || 'LTC not available';
|
||||||
card.timeEl.textContent = displayTime(ltc.time);
|
|
||||||
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
|
||||||
} else if (ltc.receiving) {
|
} else if (ltc.receiving) {
|
||||||
mode = 'receiving';
|
mode = 'receiving';
|
||||||
|
// With an anchor active, paint the frame it implies for now (this
|
||||||
|
// keeps a throttled tab advancing at the 4×/s anchor rate); the rAF
|
||||||
|
// loop refines it to every frame when the tab is visible. Without an
|
||||||
|
// anchor (rate unknown), fall back to the raw anchor timecode.
|
||||||
|
if (ltcAnchor) paintLtcFrame();
|
||||||
|
else card.timeEl.textContent = displayTime(ltc.time);
|
||||||
|
card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running';
|
||||||
|
} else if (ltc.status === 'stopped' || ltc.time) {
|
||||||
|
// LTC ceased but we know where it stopped — hold the last frame.
|
||||||
|
mode = 'stopped';
|
||||||
card.timeEl.textContent = displayTime(ltc.time);
|
card.timeEl.textContent = displayTime(ltc.time);
|
||||||
card.stateEl.textContent = 'Receiving';
|
card.stateEl.textContent = 'Stopped';
|
||||||
} else {
|
} else {
|
||||||
mode = 'nosignal';
|
mode = 'nosignal';
|
||||||
card.timeEl.textContent = displayTime(ltc.time);
|
card.timeEl.textContent = '—';
|
||||||
card.stateEl.textContent = 'No signal';
|
card.stateEl.textContent = 'No signal';
|
||||||
}
|
}
|
||||||
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
|
// The built-in listener is independent of ProPresenter, so its card
|
||||||
|
// must not gray out with the rest when only ProPresenter is unreachable.
|
||||||
|
const live = ltc.source === 'listener' ? ' src-reader' : '';
|
||||||
|
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status dot + degraded banners ──────────────────────────────── */
|
/* ── status dot + degraded banners ──────────────────────────────── */
|
||||||
|
|
||||||
function renderStatus() {
|
function renderStatus() {
|
||||||
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
|
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
|
||||||
|
// The stale gray-out spares the reader-fed LTC card — unless the feed
|
||||||
|
// from the ProdDash server itself is down, when everything is stale.
|
||||||
|
wrap.classList.toggle('is-feed-down', feedOffline);
|
||||||
if (feedOffline) {
|
if (feedOffline) {
|
||||||
bannerEl.hidden = false;
|
bannerEl.hidden = false;
|
||||||
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
|
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
|
||||||
@@ -244,7 +370,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!state.enabled) {
|
if (!state.enabled) {
|
||||||
bannerEl.hidden = true;
|
bannerEl.hidden = true;
|
||||||
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
// The LTC listener feeds us with or without ProPresenter — an
|
||||||
|
// LTC-only setup is working, not broken.
|
||||||
|
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured');
|
||||||
|
else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
@@ -262,9 +391,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
function render() {
|
function render() {
|
||||||
renderStatus();
|
renderStatus();
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
|
updateLtcAnchor();
|
||||||
|
|
||||||
|
// Admin-chosen digit font. A font swap changes glyph metrics, so re-fit
|
||||||
|
// the digit sizing when it actually changes.
|
||||||
|
const mono = state?.timerFont === 'monospace';
|
||||||
|
if (mono !== wrap.classList.contains('tm-mono')) {
|
||||||
|
wrap.classList.toggle('tm-mono', mono);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (state && !state.enabled) {
|
if (state && !state.enabled && !state.ltc?.supported) {
|
||||||
hint = 'No ProPresenter connection — set the host and port in Admin.';
|
hint = 'No ProPresenter connection — set the host and port in Admin.';
|
||||||
}
|
}
|
||||||
const items = hint ? [] : visibleItems();
|
const items = hint ? [] : visibleItems();
|
||||||
@@ -411,6 +549,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
stream = null;
|
stream = null;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
resizeObserver = null;
|
resizeObserver = null;
|
||||||
|
if (ltcRaf) {
|
||||||
|
cancelAnimationFrame(ltcRaf);
|
||||||
|
ltcRaf = 0;
|
||||||
|
}
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
onResize() {
|
onResize() {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// ltc-capture — minimal CoreAudio input capture for the Timers module's
|
||||||
|
// LTC listener.
|
||||||
|
//
|
||||||
|
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||||
|
// samples from an input device to a pipe. Production machines rarely have
|
||||||
|
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||||
|
// overkill — this is the whole job, natively. The module compiles it on
|
||||||
|
// first use when swiftc is available; otherwise build it once on any
|
||||||
|
// same-architecture Mac and copy the binary next to this source:
|
||||||
|
//
|
||||||
|
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
||||||
|
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
||||||
|
//
|
||||||
|
// ltc-capture --list
|
||||||
|
// every input-capable device: name, input channels, sample rate
|
||||||
|
// ltc-capture --device "Dante Via 16 Channel"
|
||||||
|
// streams the device's inputs as interleaved s16le to stdout, at the
|
||||||
|
// device's own sample rate and channel count (both printed to stderr
|
||||||
|
// at start). The name matches exactly first, then as a substring.
|
||||||
|
//
|
||||||
|
// --device matches case-insensitively on a substring of the device name or
|
||||||
|
// UID; ambiguity is an error listing the candidates. Capture uses an
|
||||||
|
// AVAudioEngine input tap pinned to the chosen device, converting Float32
|
||||||
|
// to clamped Int16 little-endian. stdout is samples only; every message
|
||||||
|
// goes to stderr.
|
||||||
|
//
|
||||||
|
// macOS will ask for microphone permission on first use (the prompt
|
||||||
|
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
|
||||||
|
// Over SSH there is no prompt to answer: grant it once at the console, or
|
||||||
|
// approve the terminal app under System Settings → Privacy → Microphone.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func fail(_ msg: String) -> Never {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(_ msg: String) {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||||
|
|
||||||
|
struct InputDevice {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let name: String
|
||||||
|
let uid: String
|
||||||
|
let channels: Int
|
||||||
|
let sampleRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var ref: Unmanaged<CFString>? = nil
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let err = withUnsafeMutablePointer(to: &ref) {
|
||||||
|
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||||
|
return value as String
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var rate: Float64 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float64>.size)
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||||
|
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { listPtr.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||||
|
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInputDevices() -> [InputDevice] {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
|
||||||
|
else { return [] }
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
var out: [InputDevice] = []
|
||||||
|
for id in ids {
|
||||||
|
let channels = inputChannelCount(id)
|
||||||
|
if channels == 0 { continue }
|
||||||
|
out.append(InputDevice(id: id,
|
||||||
|
name: stringProperty(id, kAudioObjectPropertyName),
|
||||||
|
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||||
|
channels: channels,
|
||||||
|
sampleRate: sampleRateProperty(id)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── args ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
var wantList = false
|
||||||
|
var wantDevice: String? = nil
|
||||||
|
var argIdx = 1
|
||||||
|
let argv = CommandLine.arguments
|
||||||
|
while argIdx < argv.count {
|
||||||
|
switch argv[argIdx] {
|
||||||
|
case "--list": wantList = true
|
||||||
|
case "--device":
|
||||||
|
argIdx += 1
|
||||||
|
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
|
||||||
|
wantDevice = argv[argIdx]
|
||||||
|
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
|
||||||
|
}
|
||||||
|
argIdx += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = listInputDevices()
|
||||||
|
|
||||||
|
if wantList || wantDevice == nil {
|
||||||
|
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||||
|
note("input devices:")
|
||||||
|
for d in devices {
|
||||||
|
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
|
||||||
|
d.channels, d.sampleRate, d.uid))
|
||||||
|
}
|
||||||
|
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||||
|
exit(wantList ? 0 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = wantDevice!.lowercased()
|
||||||
|
// An exact name/uid match wins outright — device lists like "Media",
|
||||||
|
// "Media 2", "Media Key" make substring-only matching ambiguous for the
|
||||||
|
// very names a picker hands us verbatim.
|
||||||
|
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||||
|
let matches = exact.count == 1 ? exact
|
||||||
|
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||||
|
guard matches.count == 1 else {
|
||||||
|
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||||
|
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
let device = matches[0]
|
||||||
|
|
||||||
|
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
|
||||||
|
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||||
|
var deviceID = device.id
|
||||||
|
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||||
|
kAudioUnitScope_Global, 0, &deviceID,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||||
|
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||||
|
|
||||||
|
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||||
|
let channels = Int(format.channelCount)
|
||||||
|
let rate = Int(format.sampleRate)
|
||||||
|
guard channels > 0, rate > 0 else {
|
||||||
|
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||||
|
+ "(System Settings → Privacy & Security → Microphone)")
|
||||||
|
}
|
||||||
|
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
|
||||||
|
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
|
||||||
|
|
||||||
|
let stdoutHandle = FileHandle.standardOutput
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
|
||||||
|
let frames = Int(buffer.frameLength)
|
||||||
|
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||||
|
var bytes = Data(count: frames * channels * 2)
|
||||||
|
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||||
|
let out = raw.bindMemory(to: Int16.self)
|
||||||
|
for f in 0..<frames {
|
||||||
|
for c in 0..<channels {
|
||||||
|
let v = floats[c][f]
|
||||||
|
let clamped = max(-1.0, min(1.0, v))
|
||||||
|
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
|
||||||
|
}
|
||||||
|
|
||||||
|
do { try engine.start() } catch {
|
||||||
|
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||||
|
+ "approve microphone access for the launching app and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(SIGINT) { _ in exit(0) }
|
||||||
|
signal(SIGTERM) { _ in exit(0) }
|
||||||
|
RunLoop.main.run()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in LTC listener — capture child + in-process decode.
|
||||||
|
*
|
||||||
|
* Spawns the module's native capture tool (ltc-capture, compiled from
|
||||||
|
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
||||||
|
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||||
|
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||||
|
* onUpdate roughly 4×/s:
|
||||||
|
*
|
||||||
|
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||||
|
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||||
|
*
|
||||||
|
* The capture binary is per-machine (gitignored): if it is missing this
|
||||||
|
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||||
|
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||||
|
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||||
|
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||||
|
*
|
||||||
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
|
* from every input (no error!), which shows up here as endless 'nosignal'.
|
||||||
|
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
|
||||||
|
* and approve the mic prompt once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn, spawnSync } = require('child_process');
|
||||||
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
|
const RESTART_MS = 5000;
|
||||||
|
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
|
||||||
|
retry loop would just spam the log. */
|
||||||
|
const BAD_CONFIG_RETRY_MS = 30000;
|
||||||
|
const EVAL_MS = 250;
|
||||||
|
/** Frames must decode AND advance this recently to count as running. */
|
||||||
|
const RUNNING_WINDOW_MS = 400;
|
||||||
|
|
||||||
|
/** stderr line the capture prints once the device is open. */
|
||||||
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
|
* onUpdate:(u:object)=>void}} opts
|
||||||
|
* @returns {{stop:()=>void}}
|
||||||
|
*/
|
||||||
|
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||||
|
const bin = path.join(moduleDir, 'ltc-capture');
|
||||||
|
let child = null;
|
||||||
|
let stopped = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let evalTimer = null;
|
||||||
|
let startedAt = 0;
|
||||||
|
|
||||||
|
let lastTc = '';
|
||||||
|
let fps = 0;
|
||||||
|
let df = false;
|
||||||
|
let lastFrameWall = 0;
|
||||||
|
let lastChangeWall = 0;
|
||||||
|
let lastSent = '';
|
||||||
|
|
||||||
|
function report(update) {
|
||||||
|
const key = JSON.stringify(update);
|
||||||
|
if (key === lastSent) return;
|
||||||
|
lastSent = key;
|
||||||
|
onUpdate(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotState() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
|
||||||
|
if (lastTc) return 'stopped';
|
||||||
|
return 'nosignal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBinary() {
|
||||||
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
log('ltc-capture binary missing — trying a one-time swiftc build');
|
||||||
|
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
||||||
|
if (built.status === 0 && fs.existsSync(bin)) {
|
||||||
|
log('ltc-capture compiled');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
||||||
|
+ 'and copy it here';
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped) return;
|
||||||
|
const missing = ensureBinary();
|
||||||
|
if (missing) {
|
||||||
|
report({ error: missing });
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAt = Date.now();
|
||||||
|
let extractor = null;
|
||||||
|
let decoder = null;
|
||||||
|
let stderrTail = '';
|
||||||
|
|
||||||
|
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (text) => {
|
||||||
|
stderrTail = (stderrTail + text).slice(-500);
|
||||||
|
const m = text.match(CAPTURING_RE);
|
||||||
|
if (!m) return;
|
||||||
|
const channels = Number(m[2]);
|
||||||
|
const rate = Number(m[3]);
|
||||||
|
if (channel > channels) {
|
||||||
|
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||||
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
|
||||||
|
extractor = new ChannelExtractor(channels, channel);
|
||||||
|
decoder = new LtcDecoder(rate, (frame) => {
|
||||||
|
const tc = fmtTc(frame);
|
||||||
|
const now = Date.now();
|
||||||
|
if (tc !== lastTc) lastChangeWall = now;
|
||||||
|
lastTc = tc;
|
||||||
|
fps = frame.fps;
|
||||||
|
df = frame.df;
|
||||||
|
lastFrameWall = now;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on('data', (buf) => {
|
||||||
|
if (extractor && decoder) decoder.push(extractor.push(buf));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
child = null;
|
||||||
|
if (stopped) return;
|
||||||
|
// The capture's own failure text (unknown device, ambiguous name,
|
||||||
|
// permission hint) is the most useful thing we can show.
|
||||||
|
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
|
const quickDeath = Date.now() - startedAt < 3000;
|
||||||
|
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||||
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart(ms) {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
start();
|
||||||
|
}, ms);
|
||||||
|
restartTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
evalTimer = setInterval(() => {
|
||||||
|
if (stopped || !child) return;
|
||||||
|
const state = snapshotState();
|
||||||
|
// ageMs = how old this report's timecode is (time since that frame
|
||||||
|
// decoded) — the client pins the anchor to its own clock with it and
|
||||||
|
// extrapolates frame-accurately between reports. Only while running:
|
||||||
|
// a held frame has no meaningful age, and a varying field would defeat
|
||||||
|
// the report dedupe while stopped.
|
||||||
|
const update = { state, time: lastTc, fps, df };
|
||||||
|
if (state === 'running') update.ageMs = Date.now() - lastFrameWall;
|
||||||
|
report(update);
|
||||||
|
}, EVAL_MS);
|
||||||
|
evalTimer.unref?.();
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
clearInterval(evalTimer);
|
||||||
|
if (child) {
|
||||||
|
try { child.kill(); } catch { /* already gone */ }
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLtcListener };
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
|
||||||
|
* interleaved-channel extraction, and an encoder for tests.
|
||||||
|
*
|
||||||
|
* Used in-process by the module's built-in LTC listener (ltc-listener.js).
|
||||||
|
* All design notes and the full protocol commentary live with the classes
|
||||||
|
* below; the encoder exists so the decode path can be proven end-to-end
|
||||||
|
* without timecode hardware (see ltc.selftest.js).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ══ decoder ═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */
|
||||||
|
const SYNC_WORD = 0x3ffd;
|
||||||
|
|
||||||
|
class LtcDecoder {
|
||||||
|
/**
|
||||||
|
* @param {number} sampleRate stdin PCM rate in Hz
|
||||||
|
* @param {(f:{h:number,m:number,s:number,f:number,df:boolean,fps:number})=>void} onFrame
|
||||||
|
*/
|
||||||
|
constructor(sampleRate, onFrame) {
|
||||||
|
this.sr = sampleRate;
|
||||||
|
this.onFrame = onFrame;
|
||||||
|
this.mean = 0; // DC offset tracker
|
||||||
|
this.env = 0; // decaying |sample| envelope, scales the comparator
|
||||||
|
this.level = 0; // comparator state: 1 / -1 (0 = not seen a crossing yet)
|
||||||
|
this.n = 0; // absolute sample clock
|
||||||
|
this.lastEdge = 0;
|
||||||
|
this.lastEdgeSeen = 0; // for signal-presence checks
|
||||||
|
// Samples per LTC bit. Start at the 30 fps value (the shortest possible
|
||||||
|
// bit) and let the interval EMA walk it up to the true rate.
|
||||||
|
this.T = sampleRate / (80 * 30);
|
||||||
|
this.halfAt = 0; // first half-interval of a suspected 1 bit, else 0
|
||||||
|
this.bits = new Uint8Array(80); // last 80 decoded bits, newest at [79]
|
||||||
|
this.sync = 0; // last 16 bits as an integer, newest at bit 0
|
||||||
|
this.fresh = 0; // bits decoded since the last reset — see bit()
|
||||||
|
this.prev = null; // previous valid frame, for rollover observation
|
||||||
|
this.nominal = 0; // nominal fps locked by two agreeing rollovers
|
||||||
|
this.rollCandidate = 0;
|
||||||
|
this.df = false; // drop-frame with two-frame hysteresis
|
||||||
|
this.dfPending = null;
|
||||||
|
this.dfRun = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Int16Array} samples */
|
||||||
|
push(samples) {
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
this.n += 1;
|
||||||
|
const raw = samples[i];
|
||||||
|
// Seed the DC tracker from the first sample — a real offset would
|
||||||
|
// otherwise take ~0.5 s to settle and cost the opening frames.
|
||||||
|
if (this.n === 1) this.mean = raw;
|
||||||
|
this.mean += (raw - this.mean) / 8192;
|
||||||
|
const v = raw - this.mean;
|
||||||
|
const mag = v < 0 ? -v : v;
|
||||||
|
this.env = mag > this.env ? mag : this.env * 0.9996;
|
||||||
|
// Floor keeps line noise from registering as signal during silence.
|
||||||
|
const th = Math.max(this.env * 0.3, 500);
|
||||||
|
let crossed = false;
|
||||||
|
if (this.level >= 0 && v < -th) {
|
||||||
|
this.level = -1;
|
||||||
|
crossed = true;
|
||||||
|
} else if (this.level <= 0 && v > th) {
|
||||||
|
this.level = 1;
|
||||||
|
crossed = true;
|
||||||
|
}
|
||||||
|
if (!crossed) continue;
|
||||||
|
const t = this.n - this.lastEdge;
|
||||||
|
this.lastEdge = this.n;
|
||||||
|
this.lastEdgeSeen = this.n;
|
||||||
|
this.interval(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transition-to-transition gap, in samples. */
|
||||||
|
interval(t) {
|
||||||
|
if (t > 1.9 * this.T) {
|
||||||
|
// Silence gap or garbage — drop bit state, keep the period estimate
|
||||||
|
// (the stream that resumes is usually the same deck). Frame
|
||||||
|
// continuity broke, so rollover observation restarts too; the locked
|
||||||
|
// nominal survives — two agreeing rollovers re-lock a changed rate.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.sync = 0;
|
||||||
|
this.fresh = 0;
|
||||||
|
this.prev = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t < 0.75 * this.T) {
|
||||||
|
// Half period: one of a 1-bit's pair.
|
||||||
|
if (this.halfAt) {
|
||||||
|
this.T += (this.halfAt + t - this.T) * 0.05;
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.bit(1);
|
||||||
|
} else {
|
||||||
|
this.halfAt = t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Full period: a 0 bit. A lone preceding half means we slipped —
|
||||||
|
// shed it and let the sync-word search re-align.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.T += (t - this.T) * 0.05;
|
||||||
|
this.bit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bit(b) {
|
||||||
|
this.bits.copyWithin(0, 1);
|
||||||
|
this.bits[79] = b;
|
||||||
|
this.sync = ((this.sync << 1) | b) & 0xffff;
|
||||||
|
if (this.fresh < 80) this.fresh += 1;
|
||||||
|
// Sync word just completed → bits[0..63] are one whole frame's data —
|
||||||
|
// but only once 80 real bits have displaced the cold-start/pre-gap
|
||||||
|
// buffer, or a phantom frame decodes from the padding.
|
||||||
|
if (this.sync === SYNC_WORD && this.fresh >= 80) this.frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
const bits = this.bits;
|
||||||
|
// Every LTC field is BCD, transmitted LSB-first.
|
||||||
|
const bcd = (start, len) => {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < len; i += 1) v |= bits[start + i] << i;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
// Strict validation: LTC has no parity worth the name, so a bit error
|
||||||
|
// can produce a frame that "parses" — every field must be a legal BCD
|
||||||
|
// digit AND inside its hard ceiling, or the frame is dropped whole.
|
||||||
|
// (A lax check here once let a corrupt frames field of 35 through, and
|
||||||
|
// downstream fps logic reported 36 fps to the dashboard.)
|
||||||
|
const fu = bcd(0, 4);
|
||||||
|
const su = bcd(16, 4);
|
||||||
|
const mu = bcd(32, 4);
|
||||||
|
const hu = bcd(48, 4);
|
||||||
|
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
|
||||||
|
const f = fu + bcd(8, 2) * 10;
|
||||||
|
const s = su + bcd(24, 3) * 10;
|
||||||
|
const m = mu + bcd(40, 3) * 10;
|
||||||
|
const h = hu + bcd(56, 2) * 10;
|
||||||
|
if (f > 29 || s > 59 || m > 59 || h > 23) return;
|
||||||
|
|
||||||
|
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
|
||||||
|
// frame must not flash 29.97 DF across the dashboard.
|
||||||
|
const dfBit = bits[10] === 1;
|
||||||
|
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
|
||||||
|
this.dfPending = dfBit;
|
||||||
|
if (this.dfRun >= 2) this.df = dfBit;
|
||||||
|
|
||||||
|
// Nominal rate from observed rollovers: the frame number that precedes
|
||||||
|
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
|
||||||
|
// agreeing rollovers lock it — a single corrupt frame can't fake two
|
||||||
|
// valid, correctly-successive frames twice, and a genuine rate change
|
||||||
|
// re-locks itself within two seconds.
|
||||||
|
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
|
||||||
|
const cand = this.prev.f + 1;
|
||||||
|
if (cand === 24 || cand === 25 || cand === 30) {
|
||||||
|
if (cand === this.rollCandidate) this.nominal = cand;
|
||||||
|
this.rollCandidate = cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.prev = { h, m, s, f };
|
||||||
|
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal rate: locked by rollover consensus once two second-boundaries
|
||||||
|
* agree; until then snap the measured bit clock to a standard rate.
|
||||||
|
* 29.97 vs 30 non-drop differ by 0.1% — beyond the bit-clock estimate's
|
||||||
|
* accuracy — so non-drop 29.97 reads as 30. Drop-frame is exact: the DF
|
||||||
|
* bit means 29.97.
|
||||||
|
*/
|
||||||
|
fps(df) {
|
||||||
|
if (df) return 29.97;
|
||||||
|
if (this.nominal) return this.nominal;
|
||||||
|
const measured = this.sr / (this.T * 80);
|
||||||
|
return [24, 25, 30].reduce((a, b) => (Math.abs(b - measured) < Math.abs(a - measured) ? b : a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while transitions are actually arriving (sample-clock based). */
|
||||||
|
signalPresent() {
|
||||||
|
return this.n - this.lastEdgeSeen < this.sr * 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ encoder (selftest + demo — and pipeable test audio) ═══════════════ */
|
||||||
|
|
||||||
|
/** Advance one frame under the given counting rules (fps = nominal integer). */
|
||||||
|
function nextTc(tc, fps, df) {
|
||||||
|
let { h, m, s, f } = tc;
|
||||||
|
f += 1;
|
||||||
|
if (f >= fps) {
|
||||||
|
f = 0;
|
||||||
|
s += 1;
|
||||||
|
if (s >= 60) {
|
||||||
|
s = 0;
|
||||||
|
m += 1;
|
||||||
|
if (m >= 60) {
|
||||||
|
m = 0;
|
||||||
|
h = (h + 1) % 24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop-frame: frames 00 and 01 don't exist at the top of a minute,
|
||||||
|
// except every tenth minute.
|
||||||
|
if (df && f === 0 && s === 0 && m % 10 !== 0) f = 2;
|
||||||
|
return { h, m, s, f };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 80 bits of one frame, in transmission order. User bits stay zero;
|
||||||
|
the polarity-correction/parity bit is legal to leave clear. */
|
||||||
|
function frameBits(tc, df) {
|
||||||
|
const bits = new Uint8Array(80);
|
||||||
|
const put = (start, len, v) => {
|
||||||
|
for (let i = 0; i < len; i += 1) bits[start + i] = (v >> i) & 1;
|
||||||
|
};
|
||||||
|
put(0, 4, tc.f % 10);
|
||||||
|
put(8, 2, Math.floor(tc.f / 10));
|
||||||
|
bits[10] = df ? 1 : 0;
|
||||||
|
put(16, 4, tc.s % 10);
|
||||||
|
put(24, 3, Math.floor(tc.s / 10));
|
||||||
|
put(32, 4, tc.m % 10);
|
||||||
|
put(40, 3, Math.floor(tc.m / 10));
|
||||||
|
put(48, 4, tc.h % 10);
|
||||||
|
put(56, 2, Math.floor(tc.h / 10));
|
||||||
|
const sync = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
|
||||||
|
for (let i = 0; i < 16; i += 1) bits[64 + i] = sync[i];
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LtcEncoder {
|
||||||
|
/** @param {number} fps true rate (29.97 allowed); counting uses its rounding */
|
||||||
|
constructor(sampleRate, fps, df, startTc, amplitude = 12000) {
|
||||||
|
this.nominal = Math.round(fps);
|
||||||
|
this.df = df;
|
||||||
|
this.amp = amplitude;
|
||||||
|
this.spb = sampleRate / (fps * 80); // samples per bit, fractional
|
||||||
|
this.tc = { ...startTc };
|
||||||
|
this.bits = frameBits(this.tc, df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
this.halfPending = false;
|
||||||
|
this.level = 1;
|
||||||
|
this.t = 0; // sample clock
|
||||||
|
this.nextEdge = 0; // fractional sample time of the next transition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time between this transition and the next (biphase mark). */
|
||||||
|
nextInterval() {
|
||||||
|
if (this.halfPending) {
|
||||||
|
this.halfPending = false;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
if (this.bitIdx >= 80) {
|
||||||
|
this.tc = nextTc(this.tc, this.nominal, this.df);
|
||||||
|
this.bits = frameBits(this.tc, this.df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
}
|
||||||
|
const bit = this.bits[this.bitIdx];
|
||||||
|
this.bitIdx += 1;
|
||||||
|
if (bit) {
|
||||||
|
this.halfPending = true;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
return this.spb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Int16Array} the next n samples of the running LTC stream */
|
||||||
|
render(n) {
|
||||||
|
const out = new Int16Array(n);
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
while (this.t >= this.nextEdge) {
|
||||||
|
this.level = -this.level;
|
||||||
|
this.nextEdge += this.nextInterval();
|
||||||
|
}
|
||||||
|
out[i] = this.level * this.amp;
|
||||||
|
this.t += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ shared bits ═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a raw s16le byte stream of interleaved channels into one channel's
|
||||||
|
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
|
||||||
|
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
|
||||||
|
* losing frame alignment would silently decode the wrong channel.
|
||||||
|
*/
|
||||||
|
class ChannelExtractor {
|
||||||
|
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
|
||||||
|
constructor(channels, ch) {
|
||||||
|
this.frameBytes = channels * 2;
|
||||||
|
this.offset = (ch - 1) * 2;
|
||||||
|
this.carry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
|
||||||
|
push(buf) {
|
||||||
|
if (this.carry) buf = Buffer.concat([this.carry, buf]);
|
||||||
|
const usable = buf.length - (buf.length % this.frameBytes);
|
||||||
|
this.carry = buf.length > usable ? buf.subarray(usable) : null;
|
||||||
|
const out = new Int16Array(usable / this.frameBytes);
|
||||||
|
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
|
||||||
|
out[i] = buf.readInt16LE(off);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (v) => String(v).padStart(2, '0');
|
||||||
|
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
|
||||||
|
|
||||||
|
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LTC engine self-test — encoder→decoder round trips for the module's LTC
|
||||||
|
* decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to
|
||||||
|
* decode timecode from the captured audio. No audio hardware needed.
|
||||||
|
*
|
||||||
|
* node modules/propresenter-timers/ltc.selftest.js
|
||||||
|
*
|
||||||
|
* Exits non-zero if any check fails, so it doubles as a CI smoke test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc");
|
||||||
|
|
||||||
|
/* ══ selftest ══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Deterministic PRNG so a failure reproduces. */
|
||||||
|
function mulberry32(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let x = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
|
||||||
|
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selftest() {
|
||||||
|
const SR = 48000;
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`);
|
||||||
|
if (!ok) failures += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Encode frames, mangle optionally, decode in awkward chunk sizes. */
|
||||||
|
const roundTrip = (fps, df, start, frames, mangle) => {
|
||||||
|
const enc = new LtcEncoder(SR, fps, df, start);
|
||||||
|
let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10);
|
||||||
|
if (mangle) pcm = mangle(pcm);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (let off = 0; off < pcm.length; off += 997) {
|
||||||
|
dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sequential = (out) =>
|
||||||
|
out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1]));
|
||||||
|
|
||||||
|
console.log('30 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60);
|
||||||
|
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||||
|
check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30);
|
||||||
|
check('df clear', out.every((f) => !f.df));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('25 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50);
|
||||||
|
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||||
|
check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0));
|
||||||
|
check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('24 fps:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50);
|
||||||
|
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||||
|
check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('29.97 drop-frame:');
|
||||||
|
{
|
||||||
|
const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90);
|
||||||
|
check('decodes most frames', out.length >= 80, `${out.length}/90`);
|
||||||
|
// hysteresis: the very first frame legitimately reports df=false
|
||||||
|
check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df));
|
||||||
|
check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2));
|
||||||
|
check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2));
|
||||||
|
check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('hostile audio (low level, noise, DC offset):');
|
||||||
|
{
|
||||||
|
const rand = mulberry32(1234);
|
||||||
|
const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => {
|
||||||
|
const mangled = new Int16Array(pcm.length);
|
||||||
|
for (let i = 0; i < pcm.length; i += 1) {
|
||||||
|
// ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of
|
||||||
|
// DC. Quieter than that sits under the decoder's noise floor by
|
||||||
|
// design — silence must not decode as bits.
|
||||||
|
mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000);
|
||||||
|
}
|
||||||
|
return mangled;
|
||||||
|
});
|
||||||
|
check('still decodes', out.length >= 50, `${out.length}/60`);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('signal dropout and recovery:');
|
||||||
|
{
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 });
|
||||||
|
const a = enc.render(SR); // 1 s ≈ 30 frames
|
||||||
|
const gap = new Int16Array(SR / 2); // 0.5 s of silence
|
||||||
|
const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 });
|
||||||
|
const b = enc2.render(SR);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (const part of [a, gap, b]) {
|
||||||
|
for (let off = 0; off < part.length; off += 1024) {
|
||||||
|
dec.push(part.subarray(off, Math.min(off + 1024, part.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const before = out.filter((f) => f.s < 5).length;
|
||||||
|
const after = out.filter((f) => f.s >= 10).length;
|
||||||
|
check('decodes before the gap', before >= 25, `${before}`);
|
||||||
|
check('recovers after the gap', after >= 25, `${after}`);
|
||||||
|
check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('corrupt frames must not poison the frame rate:');
|
||||||
|
{
|
||||||
|
// Reproduces a field bug: a Dante glitch produced a frame whose frames
|
||||||
|
// field read 35 yet passed the old lax validation, and max-frame-based
|
||||||
|
// fps logic reported "36 fps" to the dashboard forever after. Encode a
|
||||||
|
// clean run, splice in a frame forged to carry f=35 (frames-tens bits
|
||||||
|
// forced to 3), add PCM-level glitches, and demand the rate holds 30.
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 });
|
||||||
|
const clean1 = enc.render(SR); // ~30 valid frames
|
||||||
|
enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false);
|
||||||
|
enc.bits[8] = 1; // frames-tens bit 0
|
||||||
|
enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35
|
||||||
|
enc.bitIdx = 0;
|
||||||
|
const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame
|
||||||
|
const clean2 = enc.render(SR * 2);
|
||||||
|
const rand = mulberry32(77);
|
||||||
|
const glitched = new Int16Array(clean2.length);
|
||||||
|
glitched.set(clean2);
|
||||||
|
for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) {
|
||||||
|
for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (fr) => out.push(fr));
|
||||||
|
for (const part of [clean1, forged, glitched]) {
|
||||||
|
for (let off = 0; off < part.length; off += 997) {
|
||||||
|
dec.push(part.subarray(off, Math.min(off + 997, part.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('still decodes through the damage', out.length >= 70, `${out.length}`);
|
||||||
|
check('no frame number above 29 survives', out.every((fr) => fr.f <= 29));
|
||||||
|
check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`);
|
||||||
|
check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30);
|
||||||
|
check('df never flashes on', out.every((fr) => !fr.df));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('multichannel extraction (LTC on channel 5 of 8):');
|
||||||
|
{
|
||||||
|
// Interleave LTC into one channel of eight, with hot program audio on
|
||||||
|
// every other channel, and feed the byte stream in 997-byte chunks —
|
||||||
|
// odd on purpose, so chunk splits land mid-sample and mid-frame and
|
||||||
|
// the extractor's carry has to keep frame alignment.
|
||||||
|
const CH = 8;
|
||||||
|
const PICK = 5;
|
||||||
|
const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 });
|
||||||
|
const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames
|
||||||
|
const bytes = Buffer.alloc(ltc.length * CH * 2);
|
||||||
|
for (let i = 0; i < ltc.length; i += 1) {
|
||||||
|
for (let c = 0; c < CH; c += 1) {
|
||||||
|
const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05)));
|
||||||
|
bytes.writeInt16LE(v, (i * CH + c) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extractor = new ChannelExtractor(CH, PICK);
|
||||||
|
const out = [];
|
||||||
|
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||||
|
for (let off = 0; off < bytes.length; off += 997) {
|
||||||
|
dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length))));
|
||||||
|
}
|
||||||
|
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||||
|
check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0);
|
||||||
|
check('frames advance', sequential(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
selftest();
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
|
"proddash": ">=1.2.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
@@ -9,20 +10,48 @@
|
|||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
"defaultSize": { "w": 4, "h": 2 },
|
"defaultSize": { "w": 4, "h": 2 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"group": "ProPresenter",
|
||||||
},
|
"default": { "host": "", "port": 1025 }
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "API password (only if set in ProPresenter)",
|
"label": "API password (only if set in ProPresenter)",
|
||||||
|
"group": "ProPresenter",
|
||||||
"default": ""
|
"default": ""
|
||||||
|
},
|
||||||
|
"ltcEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "LTC listener — decode timecode from an audio input on this machine",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"ltcDevice": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Audio device",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/ltc/devices"
|
||||||
|
},
|
||||||
|
"ltcChannel": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Channel on that device (1-based)",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"timerFont": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Timer font",
|
||||||
|
"group": "Display",
|
||||||
|
"default": "default",
|
||||||
|
"options": [
|
||||||
|
{ "value": "default", "label": "Default (system)" },
|
||||||
|
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
/**
|
/**
|
||||||
* ProPresenter Timers — server part.
|
* ProPresenter Timers — server part.
|
||||||
*
|
*
|
||||||
* Polls the ProPresenter openAPI for the configured timer list, the live
|
* Polls the ProPresenter openAPI for the configured timer list and the live
|
||||||
* timer values, and (when the connected build exposes it) LTC timecode, and
|
* timer values, decodes LTC timecode from an audio input, and fans one
|
||||||
* fans one merged state object out to every tile over SSE. Browsers never
|
* merged state object out to every tile over SSE. Browsers never talk to
|
||||||
* talk to ProPresenter directly.
|
* ProPresenter or the audio hardware directly.
|
||||||
*
|
*
|
||||||
* GET /state → { state: <state> } (instant paint for new tiles)
|
* GET /state → { state: <state> } (instant paint for new tiles)
|
||||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
* GET /ltc/devices → this machine's audio input devices, for the admin
|
||||||
|
* page's device picker (select + optionsRoute).
|
||||||
*
|
*
|
||||||
* Endpoints used, verified against openapi.propresenter.com (API v1):
|
* Endpoints used, verified against openapi.propresenter.com (API v1):
|
||||||
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
|
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
|
||||||
@@ -21,12 +23,14 @@
|
|||||||
* `state` enum is stopped|running|complete|overrunning|overran — but the
|
* `state` enum is stopped|running|complete|overrunning|overran — but the
|
||||||
* spec's own response example says "overrun", so treat anything containing
|
* spec's own response example says "overrun", so treat anything containing
|
||||||
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
||||||
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
|
*
|
||||||
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
|
* LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has
|
||||||
* allowlist either) documents no timecode/LTC route at all. We probe this
|
* no timecode route, and the classic stage-display websocket drops the
|
||||||
* candidate route once per connection anyway so a future ProPresenter
|
* field — both verified dead on ProPresenter 21.4), so LTC is read from the
|
||||||
* that adds it lights up without a module update; a 404 marks LTC
|
* audio signal itself. Behind the admin "LTC listener" switch: off means no
|
||||||
* "not available on this ProPresenter" (ltc.supported = false).
|
* LTC tile in the picker and nothing captured; on, the built-in listener
|
||||||
|
* (ltc-listener.js) captures the admin-selected device/channel and decodes
|
||||||
|
* in-process. It is the module's one and only LTC source.
|
||||||
*
|
*
|
||||||
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||||||
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||||||
@@ -47,30 +51,45 @@ const REQUEST_TIMEOUT_MS = 4000;
|
|||||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
const HEARTBEAT_MS = 15000;
|
const HEARTBEAT_MS = 15000;
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { createLtcListener } = require('./ltc-listener');
|
||||||
|
|
||||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
function clearedState(enabled) {
|
function clearedState(enabled, ltcEnabled = false, timerFont = 'default') {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
ltcEnabled,
|
||||||
|
timerFont,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
lastError: '',
|
lastError: '',
|
||||||
timers: [],
|
timers: [],
|
||||||
// supported: null = not probed yet (or ProPresenter unreachable),
|
// supported true once the listener has a verdict; false when the
|
||||||
// true/false once a reachable ProPresenter answered the probe.
|
// listener is off, has no device, or its capture failed (note says
|
||||||
ltc: { supported: null, time: '', receiving: false },
|
// why). status is running|stopped|nosignal; while running, ageMs lets
|
||||||
|
// clients count frames locally between updates.
|
||||||
|
ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const host = String(config.host || '');
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
const port = Number(config.port) || 1025;
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
const password = String(config.password || '');
|
const password = String(config.password || '');
|
||||||
const enabled = Boolean(host && port);
|
const enabled = Boolean(host && port);
|
||||||
|
const ltcEnabled = Boolean(config.ltcEnabled);
|
||||||
|
const ltcDevice = String(config.ltcDevice || '');
|
||||||
|
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
|
||||||
|
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
|
||||||
|
|
||||||
let latest = clearedState(enabled);
|
let latest = clearedState(enabled, ltcEnabled, timerFont);
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
@@ -78,9 +97,35 @@ module.exports = {
|
|||||||
|
|
||||||
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
|
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
|
||||||
let timerConfigs = new Map();
|
let timerConfigs = new Map();
|
||||||
/** null = probe /v1/timecode/status on the next reachable tick. */
|
/** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */
|
||||||
let ltcSupported = null;
|
let listenerLtc = null;
|
||||||
let ltcLast = { time: '', receiving: false };
|
|
||||||
|
/** The LTC verdict — the built-in listener is the only source. */
|
||||||
|
function composeLtc() {
|
||||||
|
if (!ltcEnabled) {
|
||||||
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
|
||||||
|
}
|
||||||
|
if (!listenerLtc) {
|
||||||
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' };
|
||||||
|
}
|
||||||
|
if (listenerLtc.error) {
|
||||||
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error };
|
||||||
|
}
|
||||||
|
const running = listenerLtc.state === 'running';
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
source: 'listener',
|
||||||
|
time: listenerLtc.time,
|
||||||
|
receiving: running,
|
||||||
|
status: listenerLtc.state,
|
||||||
|
fps: listenerLtc.fps,
|
||||||
|
df: listenerLtc.df,
|
||||||
|
// While running: how old this timecode is, so clients can pin the
|
||||||
|
// anchor to their own clock and count frames between updates.
|
||||||
|
...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}),
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
@@ -160,64 +205,15 @@ module.exports = {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Liberal parse of a hypothetical timecode-status body (no spec documents
|
|
||||||
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
|
|
||||||
* string and a receiving/running boolean under likely key names.
|
|
||||||
*/
|
|
||||||
function parseLtc(body) {
|
|
||||||
const out = { time: '', receiving: false };
|
|
||||||
if (!body || typeof body !== 'object') return out;
|
|
||||||
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
|
||||||
for (const key of ['time', 'timecode', 'current_time', 'value']) {
|
|
||||||
if (typeof body[key] === 'string' && TC.test(body[key])) {
|
|
||||||
out.time = body[key];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!out.time) {
|
|
||||||
for (const value of Object.values(body)) {
|
|
||||||
if (typeof value === 'string' && TC.test(value)) {
|
|
||||||
out.time = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of ['receiving', 'running', 'is_running', 'active']) {
|
|
||||||
if (typeof body[key] === 'boolean') {
|
|
||||||
out.receiving = body[key];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollLtc() {
|
|
||||||
if (ltcSupported === false) return;
|
|
||||||
try {
|
|
||||||
const body = await requestJson('/v1/timecode/status');
|
|
||||||
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
|
|
||||||
ltcSupported = true;
|
|
||||||
ltcLast = parseLtc(body);
|
|
||||||
} catch (err) {
|
|
||||||
// 404 = this ProPresenter has no timecode API (the expected case, as
|
|
||||||
// of API v1). Anything else is a real failure — let the tick handle it.
|
|
||||||
if (err && err.status === 404) {
|
|
||||||
ltcSupported = false;
|
|
||||||
ltcLast = { time: '', receiving: false };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcast() {
|
function broadcast() {
|
||||||
latest = {
|
latest = {
|
||||||
enabled,
|
enabled,
|
||||||
|
ltcEnabled,
|
||||||
|
timerFont,
|
||||||
reachable: latest.reachable,
|
reachable: latest.reachable,
|
||||||
lastError: latest.lastError,
|
lastError: latest.lastError,
|
||||||
timers: latest.timers,
|
timers: latest.timers,
|
||||||
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
|
ltc: composeLtc(),
|
||||||
};
|
};
|
||||||
if (!streams.size) return;
|
if (!streams.size) return;
|
||||||
let frame;
|
let frame;
|
||||||
@@ -249,18 +245,12 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
// THE HOT PATH — live values every tick.
|
// THE HOT PATH — live values every tick.
|
||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
// Probe timecode once per connection; poll it only when it exists.
|
|
||||||
await pollLtc();
|
|
||||||
latest.timers = mergeTimers(currentTimes);
|
latest.timers = mergeTimers(currentTimes);
|
||||||
latest.reachable = true;
|
latest.reachable = true;
|
||||||
latest.lastError = '';
|
latest.lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
latest.reachable = false;
|
latest.reachable = false;
|
||||||
latest.lastError = err instanceof Error ? err.message : String(err);
|
latest.lastError = err instanceof Error ? err.message : String(err);
|
||||||
// Re-probe timecode after a reconnect — the unreachable spell may
|
|
||||||
// have been a ProPresenter upgrade or a different machine.
|
|
||||||
ltcSupported = null;
|
|
||||||
ltcLast = { time: '', receiving: false };
|
|
||||||
}
|
}
|
||||||
broadcast();
|
broadcast();
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@@ -274,6 +264,27 @@ module.exports = {
|
|||||||
log('no ProPresenter host configured — set one in /admin');
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The built-in listener is independent of ProPresenter — LTC-only
|
||||||
|
// setups (no host configured) still get the LTC card.
|
||||||
|
let ltcListener = null;
|
||||||
|
if (ltcEnabled) {
|
||||||
|
if (ltcDevice) {
|
||||||
|
ltcListener = createLtcListener({
|
||||||
|
device: ltcDevice,
|
||||||
|
channel: ltcChannel,
|
||||||
|
moduleDir: __dirname,
|
||||||
|
log,
|
||||||
|
onUpdate(update) {
|
||||||
|
listenerLtc = update;
|
||||||
|
broadcast();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
for (const res of streams) {
|
for (const res of streams) {
|
||||||
try {
|
try {
|
||||||
@@ -292,6 +303,7 @@ module.exports = {
|
|||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
|
ltcListener?.stop();
|
||||||
for (const res of streams) {
|
for (const res of streams) {
|
||||||
try { res.end(); } catch { /* already gone */ }
|
try { res.end(); } catch { /* already gone */ }
|
||||||
}
|
}
|
||||||
@@ -299,9 +311,19 @@ module.exports = {
|
|||||||
if (current && current.streams === streams) current = null;
|
if (current && current.streams === streams) current = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
// LTC status suffix for the admin health line.
|
||||||
|
const ltcNote = !ltcEnabled
|
||||||
|
? ''
|
||||||
|
: latest.ltc.supported
|
||||||
|
? `, LTC ${latest.ltc.status}`
|
||||||
|
: (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : '');
|
||||||
|
if (!enabled) {
|
||||||
|
// The LTC listener works regardless of ProPresenter — an
|
||||||
|
// LTC-only setup is half-configured but working.
|
||||||
|
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` };
|
||||||
|
return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
|
}
|
||||||
if (latest.reachable) {
|
if (latest.reachable) {
|
||||||
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
|
|
||||||
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -323,7 +345,7 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
tiles() {
|
tiles() {
|
||||||
const state = current ? current.getLatest() : null;
|
const state = current ? current.getLatest() : null;
|
||||||
if (!state || !state.enabled) return []; // unconfigured → classic single entry
|
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
|
||||||
const TYPE_LABEL = {
|
const TYPE_LABEL = {
|
||||||
countdown: 'Countdown timer',
|
countdown: 'Countdown timer',
|
||||||
countdown_to_time: 'Countdown to a time of day',
|
countdown_to_time: 'Countdown to a time of day',
|
||||||
@@ -332,22 +354,56 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
||||||
return [
|
return [
|
||||||
{ id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' },
|
{
|
||||||
|
id: 'all',
|
||||||
|
name: 'All timers',
|
||||||
|
description: state.ltcEnabled
|
||||||
|
? 'Every timer and the LTC timecode, selectable per tile'
|
||||||
|
: 'Every timer, selectable per tile',
|
||||||
|
},
|
||||||
...state.timers.map((t) => ({
|
...state.timers.map((t) => ({
|
||||||
id: `timer:${t.uuid}`,
|
id: `timer:${t.uuid}`,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: TYPE_LABEL[t.type] || 'Timer',
|
description: TYPE_LABEL[t.type] || 'Timer',
|
||||||
...oneCard,
|
...oneCard,
|
||||||
})),
|
})),
|
||||||
{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard },
|
// The LTC entry exists only while the admin switch is on.
|
||||||
|
...(state.ltcEnabled
|
||||||
|
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
|
const sendJson = (res, code, body) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
|
||||||
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
|
},
|
||||||
|
|
||||||
|
// The admin device picker asks this machine what it can hear. The
|
||||||
|
// options shape matches the admin select's optionsRoute contract.
|
||||||
|
'GET /ltc/devices': (req, res) => {
|
||||||
|
const bin = require('path').join(__dirname, 'ltc-capture');
|
||||||
|
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
|
let err = '';
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (t) => { err += t; });
|
||||||
|
child.on('error', () => {
|
||||||
|
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
|
||||||
|
});
|
||||||
|
child.on('exit', () => {
|
||||||
|
if (res.writableEnded) return;
|
||||||
|
const options = [];
|
||||||
|
for (const line of err.split('\n')) {
|
||||||
|
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
|
||||||
|
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { options });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /stream': (req, res) => {
|
'GET /stream': (req, res) => {
|
||||||
|
|||||||
@@ -30,8 +30,12 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||||
|
|
||||||
/* Values still on screen while the feed is down are stale — say so visually. */
|
/* Values still on screen while the feed is down are stale — say so visually.
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
|
Per-card, because the reader-fed LTC card is not stale when only
|
||||||
|
ProPresenter is unreachable; it dims only when the ProdDash feed itself
|
||||||
|
is down (is-feed-down) and everything on screen really is old. */
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; }
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-hint {
|
[data-module="propresenter-timers"] .tm-hint {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -54,7 +58,6 @@
|
|||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: var(--tm-gap, 8px);
|
gap: var(--tm-gap, 8px);
|
||||||
padding: var(--tm-pad, 10px);
|
padding: var(--tm-pad, 10px);
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-name {
|
[data-module="propresenter-timers"] .tm-name {
|
||||||
@@ -109,6 +113,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
|
||||||
|
Applies to timer and LTC values alike (both are .tm-time). */
|
||||||
|
[data-module="propresenter-timers"] .tm-mono .tm-time {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-state {
|
[data-module="propresenter-timers"] .tm-state {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -198,6 +209,10 @@
|
|||||||
|
|
||||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
|
||||||
|
stopping the clock is usually somebody doing their job. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
|
||||||
|
|
||||||
/* No timecode API on this ProPresenter: say so, quietly. */
|
/* No timecode API on this ProPresenter: say so, quietly. */
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
||||||
@@ -207,8 +222,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
|
|
||||||
|
|
||||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item {
|
[data-module="propresenter-timers"] .tm-menu-item {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "proddash",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ body { overflow: auto; }
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-storage { margin-top: -6px; font-size: 12px; }
|
||||||
|
.admin-storage code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -93,8 +105,91 @@ body { overflow: auto; }
|
|||||||
|
|
||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* labeled config subsections (manifest field `group`) */
|
||||||
|
.field-group {
|
||||||
|
margin: 14px 0 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.field-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||||
|
details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.field-group > summary.field-group-title::-webkit-details-marker { display: none; }
|
||||||
|
details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
details.field-group[open] > summary.field-group-title { margin-bottom: 10px; }
|
||||||
|
details.field-group > summary.field-group-title:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* multi-column groups (configGroups[name].columns): a neat checklist grid */
|
||||||
|
.field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
||||||
|
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
.field-group-help,
|
||||||
|
.field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.field-group-help { margin: -4px 0 8px; }
|
||||||
|
.field-help { margin-top: 2px; }
|
||||||
|
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
|
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
|
||||||
|
default and the field stays visible. */
|
||||||
|
.module-form .field[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* a boolean rendered as a toggle (manifest type `switch`) */
|
||||||
|
.field.switch-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.field.switch-field .switch-label { font-size: 13px; }
|
||||||
|
|
||||||
.module-actions {
|
.module-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -152,3 +247,68 @@ body { overflow: auto; }
|
|||||||
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; }
|
||||||
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
.layout-row .meta { color: var(--muted); font-size: 11px; }
|
||||||
.layouts-empty { color: var(--muted); font-size: 13px; }
|
.layouts-empty { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── version, update banner, catalog ───────────────────────────────── */
|
||||||
|
|
||||||
|
.admin-version {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-card {
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--warn) 7%, var(--panel));
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.update-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.update-head strong { font-size: 14px; }
|
||||||
|
.update-head .spacer { flex: 1; }
|
||||||
|
.update-card .admin-copy { margin: 8px 0 0; }
|
||||||
|
|
||||||
|
.btn.small { padding: 4px 9px; font-size: 12px; }
|
||||||
|
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
|
||||||
|
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); }
|
||||||
|
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
a.btn { text-decoration: none; }
|
||||||
|
|
||||||
|
.module-source {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.module-update-slot:empty { display: none; }
|
||||||
|
|
||||||
|
.catalog-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.catalog-toolbar .admin-status { margin-top: 0; }
|
||||||
|
|
||||||
|
.catalog-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.catalog-main { flex: 1; min-width: 0; }
|
||||||
|
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|||||||
+480
-14
@@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list');
|
|||||||
const layoutsSection = document.getElementById('layouts-section');
|
const layoutsSection = document.getElementById('layouts-section');
|
||||||
const layoutsList = document.getElementById('layouts-list');
|
const layoutsList = document.getElementById('layouts-list');
|
||||||
const toastEl = document.getElementById('toast');
|
const toastEl = document.getElementById('toast');
|
||||||
|
const availableSection = document.getElementById('available-section');
|
||||||
|
const availableList = document.getElementById('available-list');
|
||||||
|
const catalogStatus = document.getElementById('catalog-status');
|
||||||
|
const updateCard = document.getElementById('update-card');
|
||||||
|
const updateTitle = document.getElementById('update-title');
|
||||||
|
const updateCopy = document.getElementById('update-copy');
|
||||||
|
const updateStatus = document.getElementById('update-status');
|
||||||
|
const updateBtn = document.getElementById('update-btn');
|
||||||
|
const updateChanges = document.getElementById('update-changes');
|
||||||
|
const shellVersionEl = document.getElementById('shell-version');
|
||||||
|
|
||||||
|
/** The shell version the page loaded with — the update flow watches it change. */
|
||||||
|
let shellVersion = '';
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function toast(msg, isError = false) {
|
function toast(msg, isError = false) {
|
||||||
@@ -42,7 +55,9 @@ async function api(path, opts = {}) {
|
|||||||
function showLogin() {
|
function showLogin() {
|
||||||
loginCard.hidden = false;
|
loginCard.hidden = false;
|
||||||
modulesSection.hidden = true;
|
modulesSection.hidden = true;
|
||||||
|
availableSection.hidden = true;
|
||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
|
updateCard.hidden = true;
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,32 +98,143 @@ function healthText(mod) {
|
|||||||
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldFor(key, spec, value, passwordSet) {
|
function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : '');
|
||||||
let read;
|
let read;
|
||||||
|
// For boolean/switch fields, the control other fields may depend on
|
||||||
|
// (showWhen) — returned so the caller can wire visibility.
|
||||||
|
let control = null;
|
||||||
|
// For selects: the element (optionsDependsOn sources) and, with an
|
||||||
|
// optionsRoute, a function that (re)loads the options.
|
||||||
|
let selectEl = null;
|
||||||
|
let refetch = null;
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'switch') {
|
||||||
|
// Same toggle as the module enable switch, inline with its label.
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.className = 'switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.checked = Boolean(value);
|
||||||
|
const track = document.createElement('span');
|
||||||
|
track.className = 'track';
|
||||||
|
toggle.append(input, track);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'switch-label';
|
||||||
|
text.textContent = label;
|
||||||
|
field.append(toggle, text);
|
||||||
|
read = () => input.checked;
|
||||||
|
control = input;
|
||||||
|
} else if (type === 'boolean') {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.checked = Boolean(value);
|
input.checked = Boolean(value);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
read = () => input.checked;
|
read = () => input.checked;
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
control = input;
|
||||||
|
} else if (type === 'endpoint') {
|
||||||
|
// One consistent "Host / IP : Port" control for every module's upstream.
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const row = document.createElement('span');
|
||||||
|
row.className = 'endpoint-row';
|
||||||
|
const host = document.createElement('input');
|
||||||
|
host.type = 'text';
|
||||||
|
host.className = 'ep-host';
|
||||||
|
host.placeholder = 'Host / IP';
|
||||||
|
host.autocomplete = 'off';
|
||||||
|
host.spellcheck = false;
|
||||||
|
host.value = value?.host ? String(value.host) : '';
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'ep-sep';
|
||||||
|
sep.textContent = ':';
|
||||||
|
const port = document.createElement('input');
|
||||||
|
port.type = 'number';
|
||||||
|
port.className = 'ep-port';
|
||||||
|
port.placeholder = 'Port';
|
||||||
|
port.min = '1';
|
||||||
|
port.max = '65535';
|
||||||
|
port.value = value?.port ? String(value.port) : '';
|
||||||
|
row.append(host, sep, port);
|
||||||
|
field.append(span, row);
|
||||||
|
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
||||||
|
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
for (const opt of spec.options) {
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
|
const fill = (options) => {
|
||||||
|
// Keep what the admin has picked but not yet saved across a refill
|
||||||
|
// (a dependent select refetching), else the saved value.
|
||||||
|
const saved = String(value ?? '');
|
||||||
|
const wanted = select.options.length ? select.value : saved;
|
||||||
|
select.innerHTML = '';
|
||||||
|
// The saved value must stay selectable even when it isn't among the
|
||||||
|
// live options (device unplugged, module route down).
|
||||||
|
if (saved && !options.some((opt) => optValue(opt) === saved)) {
|
||||||
|
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||||
|
}
|
||||||
|
// Nothing saved yet → a blank "choose" entry, unless the module already
|
||||||
|
// offers a blank option of its own (an "Automatic" default, say).
|
||||||
|
if (!saved && !options.some((opt) => optValue(opt) === '')) {
|
||||||
|
options = [{ value: '', label: '— choose —' }, ...options];
|
||||||
|
}
|
||||||
|
// Options may carry a `group` (a folder path, a device class): options
|
||||||
|
// sharing one render under a single <optgroup>, in first-seen order.
|
||||||
|
const groups = new Map();
|
||||||
|
for (const opt of options) {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
o.value = optValue(opt);
|
||||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
|
||||||
|
if (!g) {
|
||||||
select.appendChild(o);
|
select.appendChild(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groups.has(g)) {
|
||||||
|
const og = document.createElement('optgroup');
|
||||||
|
og.label = g;
|
||||||
|
groups.set(g, og);
|
||||||
|
select.appendChild(og);
|
||||||
|
}
|
||||||
|
groups.get(g).appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved;
|
||||||
|
};
|
||||||
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
|
if (spec.optionsRoute && moduleId) {
|
||||||
|
// Options discovered at runtime (audio devices, ports, sources…):
|
||||||
|
// the module serves them from one of its own routes. With
|
||||||
|
// optionsDependsOn the caller triggers the fetch (and re-fetches when
|
||||||
|
// that other field changes), passing its value as a query parameter.
|
||||||
|
refetch = (query) => {
|
||||||
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (Array.isArray(body?.options)) fill(body.options);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
|
};
|
||||||
|
if (!spec.optionsDependsOn) refetch();
|
||||||
}
|
}
|
||||||
select.value = String(value ?? '');
|
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
|
selectEl = select;
|
||||||
|
} else if (type === 'color') {
|
||||||
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
|
field.classList.add('color-field');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'color';
|
||||||
|
const hex = (v, fb) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '').trim()) ? String(v).trim().toLowerCase() : fb);
|
||||||
|
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
||||||
|
field.append(span, input);
|
||||||
|
read = () => input.value;
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -123,7 +249,32 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
read = () => (type === 'number' ? Number(input.value) : input.value);
|
read = () => (type === 'number' ? Number(input.value) : input.value);
|
||||||
}
|
}
|
||||||
return { field, read };
|
if (spec?.help) {
|
||||||
|
// One line of guidance under the control (allowed placeholders, where a
|
||||||
|
// value comes from…) — the label stays short.
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
return { field, read, control, selectEl, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does a config group have anything entered? Drives `collapsed: "whenSet"`:
|
||||||
|
* a credentials block stays folded once it holds a saved secret or a value
|
||||||
|
* other than the schema default, and opens by itself while still empty.
|
||||||
|
*/
|
||||||
|
function groupHasValues(fields, mod) {
|
||||||
|
return fields.some(({ key, spec }) => {
|
||||||
|
const type = spec?.type || 'string';
|
||||||
|
if (type === 'password') return Boolean(mod.passwordSet[key]);
|
||||||
|
if (type === 'boolean' || type === 'switch') return false;
|
||||||
|
const v = mod.config[key];
|
||||||
|
if (type === 'endpoint') return Boolean(v?.host);
|
||||||
|
if (v === undefined || v === null || String(v) === '') return false;
|
||||||
|
return !('default' in (spec || {})) || String(v) !== String(spec.default);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
@@ -144,9 +295,43 @@ function buildModuleCard(mod) {
|
|||||||
version.className = 'module-version';
|
version.className = 'module-version';
|
||||||
version.textContent = mod.version ? 'v' + mod.version : '';
|
version.textContent = mod.version ? 'v' + mod.version : '';
|
||||||
|
|
||||||
|
const source = document.createElement('span');
|
||||||
|
source.className = 'module-source';
|
||||||
|
source.textContent = mod.source === 'installed' ? 'installed' : 'bundled';
|
||||||
|
source.title = mod.source === 'installed'
|
||||||
|
? 'Installed from the repo into the data directory'
|
||||||
|
: 'Ships with this copy of ProdDash';
|
||||||
|
|
||||||
const spacer = document.createElement('span');
|
const spacer = document.createElement('span');
|
||||||
spacer.className = 'spacer';
|
spacer.className = 'spacer';
|
||||||
|
|
||||||
|
// filled by the catalog when the repo has a newer version
|
||||||
|
const updateSlot = document.createElement('span');
|
||||||
|
updateSlot.className = 'module-update-slot';
|
||||||
|
|
||||||
|
const uninstall = document.createElement('button');
|
||||||
|
uninstall.className = 'btn small danger';
|
||||||
|
uninstall.textContent = 'Uninstall';
|
||||||
|
uninstall.title = mod.source === 'installed'
|
||||||
|
? 'Remove this module\'s files from the data directory'
|
||||||
|
: 'Hide this bundled module from every dashboard (its folder stays with the app)';
|
||||||
|
uninstall.addEventListener('click', async () => {
|
||||||
|
const what = mod.source === 'installed'
|
||||||
|
? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.`
|
||||||
|
: `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`;
|
||||||
|
if (!window.confirm(what)) return;
|
||||||
|
uninstall.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' });
|
||||||
|
toast(`${mod.name} uninstalled`);
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
uninstall.disabled = false;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const toggleLabel = document.createElement('label');
|
const toggleLabel = document.createElement('label');
|
||||||
toggleLabel.className = 'switch';
|
toggleLabel.className = 'switch';
|
||||||
const toggle = document.createElement('input');
|
const toggle = document.createElement('input');
|
||||||
@@ -158,7 +343,7 @@ function buildModuleCard(mod) {
|
|||||||
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
toggleLabel.append(toggle, track, toggleText);
|
toggleLabel.append(toggle, track, toggleText);
|
||||||
|
|
||||||
head.append(dot, name, version, spacer, toggleLabel);
|
head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall);
|
||||||
card.appendChild(head);
|
card.appendChild(head);
|
||||||
|
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
@@ -173,11 +358,13 @@ function buildModuleCard(mod) {
|
|||||||
|
|
||||||
const mountError = document.createElement('div');
|
const mountError = document.createElement('div');
|
||||||
mountError.className = 'module-mount-error';
|
mountError.className = 'module-mount-error';
|
||||||
mountError.hidden = !mod.mountError;
|
mountError.hidden = !mod.mountError && !mod.incompatible;
|
||||||
mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
mountError.textContent = mod.incompatible
|
||||||
|
? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.`
|
||||||
|
: (mod.mountError ? `Failed to start: ${mod.mountError}` : '');
|
||||||
card.appendChild(mountError);
|
card.appendChild(mountError);
|
||||||
|
|
||||||
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError });
|
liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot });
|
||||||
|
|
||||||
toggle.addEventListener('change', async () => {
|
toggle.addEventListener('change', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -200,10 +387,88 @@ function buildModuleCard(mod) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'module-form';
|
form.className = 'module-form';
|
||||||
const readers = new Map();
|
const readers = new Map();
|
||||||
|
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
||||||
|
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
||||||
|
const dependents = []; // { field, when } to show/hide by a control's state
|
||||||
|
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
||||||
|
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
||||||
|
const collapsibles = []; // { el, meta, fields } groups that fold
|
||||||
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
|
// `configGroups[name]` adds { collapsed, columns, help } for that group.
|
||||||
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = form;
|
||||||
|
let currentGroup = null;
|
||||||
for (const [key, spec] of schemaKeys) {
|
for (const [key, spec] of schemaKeys) {
|
||||||
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]);
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
currentGroup = null;
|
||||||
|
if (g) {
|
||||||
|
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||||||
|
const folds = meta.collapsed !== undefined;
|
||||||
|
const group = document.createElement(folds ? 'details' : 'div');
|
||||||
|
group.className = 'field-group';
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
title.textContent = g;
|
||||||
|
group.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'field-group-body';
|
||||||
|
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||||
|
if (cols > 1) {
|
||||||
|
body.classList.add('is-columns');
|
||||||
|
body.style.setProperty('--cols', String(cols));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
form.appendChild(group);
|
||||||
|
container = body;
|
||||||
|
currentGroup = { el: group, meta, fields: [] };
|
||||||
|
if (folds) collapsibles.push(currentGroup);
|
||||||
|
} else {
|
||||||
|
container = form;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
|
||||||
readers.set(key, read);
|
readers.set(key, read);
|
||||||
form.appendChild(field);
|
if (control) controls.set(key, control);
|
||||||
|
if (selectEl) selects.set(key, selectEl);
|
||||||
|
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
||||||
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
|
container.appendChild(field);
|
||||||
|
}
|
||||||
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
|
for (const dep of dependents) {
|
||||||
|
const ctl = controls.get(dep.when);
|
||||||
|
if (!ctl) continue;
|
||||||
|
const sync = () => { dep.field.hidden = !ctl.checked; };
|
||||||
|
ctl.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
// A select with optionsDependsOn:"K" loads its options with K's current
|
||||||
|
// value as a query parameter, now and whenever K changes (a plan list
|
||||||
|
// that follows the chosen service type).
|
||||||
|
for (const dep of optionDeps) {
|
||||||
|
const src = selects.get(dep.on) || controls.get(dep.on);
|
||||||
|
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
|
||||||
|
if (src) src.addEventListener('change', run);
|
||||||
|
reloaders.push(run);
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
// Collapsible groups: `collapsed: true` starts folded, `false` open,
|
||||||
|
// "whenSet" folded only once the group already holds values.
|
||||||
|
for (const grp of collapsibles) {
|
||||||
|
const c = grp.meta.collapsed;
|
||||||
|
grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod)));
|
||||||
}
|
}
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'module-actions';
|
actions.className = 'module-actions';
|
||||||
@@ -230,6 +495,10 @@ function buildModuleCard(mod) {
|
|||||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
||||||
status.className = 'admin-status ok';
|
status.className = 'admin-status ok';
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
// What the module can list may have changed (credentials just
|
||||||
|
// saved → the upstream is reachable now): reload the dynamic selects.
|
||||||
|
// The re-init is quick but not instant; give it a beat.
|
||||||
|
setTimeout(() => { for (const reload of reloaders) reload(); }, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = e.message;
|
status.textContent = e.message;
|
||||||
status.className = 'admin-status error';
|
status.className = 'admin-status error';
|
||||||
@@ -263,8 +532,10 @@ async function refreshStatus() {
|
|||||||
els.dot.className = 'dot ' + healthClass(mod);
|
els.dot.className = 'dot ' + healthClass(mod);
|
||||||
els.healthEl.textContent = healthText(mod);
|
els.healthEl.textContent = healthText(mod);
|
||||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||||
|
if (!mod.incompatible) {
|
||||||
els.mountError.hidden = !mod.mountError;
|
els.mountError.hidden = !mod.mountError;
|
||||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||||
|
}
|
||||||
if (document.activeElement !== els.toggle) {
|
if (document.activeElement !== els.toggle) {
|
||||||
els.toggle.checked = mod.enabled;
|
els.toggle.checked = mod.enabled;
|
||||||
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled';
|
||||||
@@ -356,14 +627,209 @@ async function loadState() {
|
|||||||
}
|
}
|
||||||
loginCard.hidden = true;
|
loginCard.hidden = true;
|
||||||
modulesSection.hidden = false;
|
modulesSection.hidden = false;
|
||||||
|
availableSection.hidden = false;
|
||||||
|
shellVersion = String(state.version || '');
|
||||||
|
shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : '';
|
||||||
|
const storageNote = document.getElementById('storage-note');
|
||||||
|
if (state.dataDir) {
|
||||||
|
document.getElementById('storage-path').textContent = state.dataDir;
|
||||||
|
storageNote.hidden = false;
|
||||||
|
}
|
||||||
modulesList.innerHTML = '';
|
modulesList.innerHTML = '';
|
||||||
liveEls.clear();
|
liveEls.clear();
|
||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
|
loadCatalog(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalog(refresh) {
|
||||||
|
catalogStatus.className = 'admin-status';
|
||||||
|
catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…';
|
||||||
|
let cat;
|
||||||
|
try {
|
||||||
|
cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : ''));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.authRequired) return showLogin();
|
||||||
|
catalogStatus.className = 'admin-status error';
|
||||||
|
catalogStatus.textContent = e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderShell(cat.shell || {});
|
||||||
|
renderAvailable(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(shell) {
|
||||||
|
const remote = shell.remote || {};
|
||||||
|
if (remote.error) {
|
||||||
|
catalogStatus.className = 'admin-status error';
|
||||||
|
catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`;
|
||||||
|
} else {
|
||||||
|
catalogStatus.className = 'admin-status';
|
||||||
|
catalogStatus.textContent = `${shell.repo}@${shell.branch}` +
|
||||||
|
(remote.version ? ` · latest ProdDash ${remote.version}` : '') +
|
||||||
|
(remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : '');
|
||||||
|
}
|
||||||
|
if (!shell.updateAvailable) {
|
||||||
|
updateCard.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCard.hidden = false;
|
||||||
|
updateTitle.textContent = shell.versionBehind
|
||||||
|
? `ProdDash ${remote.version} is available`
|
||||||
|
: `New commits on ${shell.branch}`;
|
||||||
|
const where = shell.local?.isGit
|
||||||
|
? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull`
|
||||||
|
: 'not a git checkout; updating unpacks the repo archive over the app folder';
|
||||||
|
updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`;
|
||||||
|
updateChanges.href = shell.changesUrl || '#';
|
||||||
|
updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1';
|
||||||
|
if (shell.updateBlocker) {
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`;
|
||||||
|
} else if (updateBtn.dataset.busy !== '1') {
|
||||||
|
updateStatus.className = 'admin-status';
|
||||||
|
updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvailable(cat) {
|
||||||
|
availableList.innerHTML = '';
|
||||||
|
const updates = cat.updates || [];
|
||||||
|
// newer versions of installed modules → a button on their card
|
||||||
|
for (const els of liveEls.values()) els.updateSlot.innerHTML = '';
|
||||||
|
for (const up of updates) {
|
||||||
|
const els = liveEls.get(up.id);
|
||||||
|
if (!els) continue;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn small primary';
|
||||||
|
btn.textContent = `Update to v${up.version}`;
|
||||||
|
btn.disabled = !up.compatible;
|
||||||
|
btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason;
|
||||||
|
btn.addEventListener('click', () => installFromCatalog(up, btn));
|
||||||
|
els.updateSlot.appendChild(btn);
|
||||||
|
}
|
||||||
|
const available = cat.available || [];
|
||||||
|
if (!available.length) {
|
||||||
|
const none = document.createElement('div');
|
||||||
|
none.className = 'layouts-empty';
|
||||||
|
none.textContent = cat.shell?.remote?.error
|
||||||
|
? 'The repo could not be reached, and nothing bundled is waiting to be installed.'
|
||||||
|
: 'Everything the repo offers is installed.';
|
||||||
|
availableList.appendChild(none);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of available) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'catalog-row';
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'catalog-main';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'catalog-title';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'module-name';
|
||||||
|
name.textContent = item.name;
|
||||||
|
const version = document.createElement('span');
|
||||||
|
version.className = 'module-version';
|
||||||
|
version.textContent = item.version ? 'v' + item.version : '';
|
||||||
|
const source = document.createElement('span');
|
||||||
|
source.className = 'module-source';
|
||||||
|
source.textContent = item.source === 'bundled' ? 'bundled' : 'repo';
|
||||||
|
source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo';
|
||||||
|
title.append(name, version, source);
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'module-desc';
|
||||||
|
desc.textContent = item.description || '';
|
||||||
|
const req = document.createElement('div');
|
||||||
|
req.className = 'module-health' + (item.compatible ? '' : ' error');
|
||||||
|
req.textContent = item.compatible
|
||||||
|
? (item.requires ? `Requires ProdDash ${item.requires}` : '')
|
||||||
|
: item.reason;
|
||||||
|
main.append(title, desc, req);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn small primary';
|
||||||
|
btn.textContent = 'Install';
|
||||||
|
btn.disabled = !item.compatible;
|
||||||
|
btn.title = item.compatible ? '' : item.reason;
|
||||||
|
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||||
|
row.append(main, btn);
|
||||||
|
availableList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installFromCatalog(item, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = 'Installing…';
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' });
|
||||||
|
toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`);
|
||||||
|
await loadState();
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = label;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** After the update the server exits and comes back; wait for it. */
|
||||||
|
async function waitForRestart(before) {
|
||||||
|
const started = Date.now();
|
||||||
|
let sawDown = false;
|
||||||
|
while (Date.now() - started < 90000) {
|
||||||
|
await sleep(1500);
|
||||||
|
try {
|
||||||
|
const s = await api('/api/admin/state');
|
||||||
|
if (sawDown || s.version !== before) {
|
||||||
|
toast(`ProdDash ${s.version} is running`);
|
||||||
|
await sleep(600);
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again
|
||||||
|
sawDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.';
|
||||||
|
updateBtn.dataset.busy = '';
|
||||||
|
updateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBtn.addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return;
|
||||||
|
updateBtn.dataset.busy = '1';
|
||||||
|
updateBtn.disabled = true;
|
||||||
|
updateStatus.className = 'admin-status';
|
||||||
|
updateStatus.textContent = 'Updating…';
|
||||||
|
const before = shellVersion;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||||||
|
updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`;
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus.className = 'admin-status error';
|
||||||
|
updateStatus.textContent = e.message;
|
||||||
|
updateBtn.dataset.busy = '';
|
||||||
|
updateBtn.disabled = false;
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await waitForRestart(before);
|
||||||
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (modulesSection.hidden) return;
|
if (modulesSection.hidden) return;
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
|||||||
+27
-1
@@ -11,7 +11,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span>
|
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span> <span class="admin-version" id="shell-version"></span></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a class="tb-btn" href="/">← Dashboard</a>
|
<a class="tb-btn" href="/">← Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
@@ -27,14 +27,40 @@
|
|||||||
<div class="admin-status" id="login-status"></div>
|
<div class="admin-status" id="login-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-card update-card" id="update-card" hidden>
|
||||||
|
<div class="update-head">
|
||||||
|
<span class="dot connecting"></span>
|
||||||
|
<strong id="update-title">Update available</strong>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="btn small" id="update-changes" href="#" target="_blank" rel="noopener">What changed</a>
|
||||||
|
<button class="btn small primary" id="update-btn">Update now</button>
|
||||||
|
</div>
|
||||||
|
<p class="admin-copy" id="update-copy"></p>
|
||||||
|
<div class="admin-status" id="update-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
<h1 class="admin-h1">Modules</h1>
|
<h1 class="admin-h1">Modules</h1>
|
||||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||||
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
||||||
live in each tile's gear menu instead.</p>
|
live in each tile's gear menu instead.</p>
|
||||||
|
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
|
||||||
|
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
|
||||||
<div id="modules-list"></div>
|
<div id="modules-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="available-section" hidden>
|
||||||
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
|
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
|
||||||
|
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
|
||||||
|
newer ProdDash can't be installed until the shell is updated.</p>
|
||||||
|
<div class="catalog-toolbar">
|
||||||
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="available-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="layouts-section" hidden>
|
<section id="layouts-section" hidden>
|
||||||
<h1 class="admin-h1">Named layouts</h1>
|
<h1 class="admin-h1">Named layouts</h1>
|
||||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||||
|
|||||||
+12
-2
@@ -15,10 +15,18 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
||||||
|
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||||
|
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
||||||
<div class="menu" id="add-menu" hidden></div>
|
<div class="menu" id="add-menu" hidden></div>
|
||||||
@@ -35,7 +43,7 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
|
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +51,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
||||||
|
|
||||||
<main id="gridwrap">
|
<main id="gridwrap">
|
||||||
<div id="grid">
|
<div id="grid">
|
||||||
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
||||||
|
|||||||
+349
-7
@@ -273,6 +273,15 @@ function buildTile(tile) {
|
|||||||
resize.className = 'tile-resize';
|
resize.className = 'tile-resize';
|
||||||
resize.title = 'Drag to resize';
|
resize.title = 'Drag to resize';
|
||||||
|
|
||||||
|
const resizeLeft = document.createElement('div');
|
||||||
|
resizeLeft.className = 'tile-resize-left';
|
||||||
|
resizeLeft.title = 'Drag to resize';
|
||||||
|
|
||||||
|
// pill on the bottom edge: moves that edge (trading rows with tiles below)
|
||||||
|
const resizeBottom = document.createElement('div');
|
||||||
|
resizeBottom.className = 'tile-resize-bottom';
|
||||||
|
resizeBottom.title = 'Drag to adjust height';
|
||||||
|
|
||||||
// notch on the title bar's bottom edge: hides/shows the bar
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
const notch = document.createElement('button');
|
const notch = document.createElement('button');
|
||||||
notch.className = 'tile-notch';
|
notch.className = 'tile-notch';
|
||||||
@@ -284,7 +293,7 @@ function buildTile(tile) {
|
|||||||
saveLayout();
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
@@ -293,7 +302,9 @@ function buildTile(tile) {
|
|||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
wireDrag(tile, el, head);
|
wireDrag(tile, el, head);
|
||||||
wireResize(tile, el, resize);
|
wireResize(tile, el, resize, false);
|
||||||
|
wireResize(tile, el, resizeLeft, true);
|
||||||
|
wireBottomResize(tile, el, resizeBottom);
|
||||||
|
|
||||||
mountModule(tile);
|
mountModule(tile);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -309,6 +320,7 @@ function wireDrag(tile, el, handle) {
|
|||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
handle.setPointerCapture(ev.pointerId);
|
handle.setPointerCapture(ev.pointerId);
|
||||||
@@ -356,17 +368,19 @@ function wireDrag(tile, el, handle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── drag to resize ─────────────────────────────────────────────────── */
|
/* ── drag to resize (bottom-right and bottom-left corners) ──────────── */
|
||||||
|
|
||||||
function wireResize(tile, el, handle) {
|
function wireResize(tile, el, handle, fromLeft) {
|
||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
handle.setPointerCapture(ev.pointerId);
|
handle.setPointerCapture(ev.pointerId);
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
const sx = ev.clientX;
|
const sx = ev.clientX;
|
||||||
const sy = ev.clientY;
|
const sy = ev.clientY;
|
||||||
|
const startX = tile.x;
|
||||||
const startW = tile.w;
|
const startW = tile.w;
|
||||||
const startH = tile.h;
|
const startH = tile.h;
|
||||||
const min = minSizeOf(tile);
|
const min = minSizeOf(tile);
|
||||||
@@ -377,13 +391,26 @@ function wireResize(tile, el, handle) {
|
|||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||||
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
const cand = {
|
let cand;
|
||||||
|
if (fromLeft) {
|
||||||
|
// the left edge moves; the right edge stays put
|
||||||
|
const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
|
||||||
|
cand = {
|
||||||
|
x: newX,
|
||||||
|
y: tile.y,
|
||||||
|
w: startX + startW - newX,
|
||||||
|
h: Math.max(min.h, startH + dRows),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cand = {
|
||||||
x: tile.x,
|
x: tile.x,
|
||||||
y: tile.y,
|
y: tile.y,
|
||||||
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||||
h: Math.max(min.h, startH + dRows),
|
h: Math.max(min.h, startH + dRows),
|
||||||
};
|
};
|
||||||
if ((cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
|
}
|
||||||
|
if ((cand.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
|
||||||
|
tile.x = cand.x;
|
||||||
tile.w = cand.w;
|
tile.w = cand.w;
|
||||||
tile.h = cand.h;
|
tile.h = cand.h;
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
@@ -408,6 +435,79 @@ function wireResize(tile, el, handle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
||||||
|
|
||||||
|
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
||||||
|
directly below it (sharing the edge), the drag trades rows with them —
|
||||||
|
the column's total height stays the same, like the seam pill trades
|
||||||
|
columns. With nothing directly below, it simply changes this tile's
|
||||||
|
height. */
|
||||||
|
|
||||||
|
function tilesDirectlyBelow(tile) {
|
||||||
|
return tiles.filter((t) => t !== tile
|
||||||
|
&& t.y === tile.y + tile.h
|
||||||
|
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireBottomResize(tile, el, handle) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeSettingsPopovers();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startH = tile.h;
|
||||||
|
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
|
||||||
|
const group = [tile.id, ...below.map((b) => b.t.id)];
|
||||||
|
const minH = minSizeOf(tile).h;
|
||||||
|
// the edge can rise until this tile hits its minimum, and drop until the
|
||||||
|
// shortest neighbour below hits its own
|
||||||
|
const maxH = below.length
|
||||||
|
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
|
||||||
|
: Infinity;
|
||||||
|
el.classList.add('resizing');
|
||||||
|
grid.classList.add('no-dividers');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
|
const h = Math.max(minH, Math.min(maxH, startH + dRows));
|
||||||
|
if (h === tile.h || h > maxH) return;
|
||||||
|
const edge = tile.y + h;
|
||||||
|
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
|
||||||
|
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
|
||||||
|
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
|
||||||
|
tile.h = h;
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
below.forEach((b, i) => {
|
||||||
|
b.t.y = candBelow[i].y;
|
||||||
|
b.t.h = candBelow[i].h;
|
||||||
|
applyRect(b.t);
|
||||||
|
notifyResize(b.t);
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
grid.classList.remove('no-dividers');
|
||||||
|
if (changed) {
|
||||||
|
saveLayout();
|
||||||
|
rebuildDividers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function notifyResize(tile) {
|
function notifyResize(tile) {
|
||||||
const entry = tileEls.get(tile.id);
|
const entry = tileEls.get(tile.id);
|
||||||
try {
|
try {
|
||||||
@@ -452,6 +552,7 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
|
|
||||||
el.addEventListener('pointerdown', (ev) => {
|
el.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
el.setPointerCapture(ev.pointerId);
|
el.setPointerCapture(ev.pointerId);
|
||||||
@@ -818,6 +919,14 @@ function unmountModule(tile) {
|
|||||||
|
|
||||||
/* ── per-tile settings popover ──────────────────────────────────────── */
|
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
|
||||||
|
function colorHex(value, fallback) {
|
||||||
|
const s = String(value ?? '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function closeSettingsPopovers() {
|
function closeSettingsPopovers() {
|
||||||
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
||||||
}
|
}
|
||||||
@@ -833,11 +942,54 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
pop.className = 'tile-settings';
|
pop.className = 'tile-settings';
|
||||||
const inputs = new Map();
|
const inputs = new Map();
|
||||||
|
|
||||||
|
// Fields render in schema order; a field's `group` starts a labeled
|
||||||
|
// subsection that consecutive same-group fields share, and the manifest's
|
||||||
|
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
||||||
|
// the same conventions as the admin page's config form.
|
||||||
|
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = pop;
|
||||||
|
|
||||||
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||||
|
|
||||||
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
if (g) {
|
||||||
|
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||||||
|
const folds = meta.collapsed !== undefined;
|
||||||
|
const group = document.createElement(folds ? 'details' : 'div');
|
||||||
|
group.className = 'field-group';
|
||||||
|
if (folds) group.open = !meta.collapsed;
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
title.textContent = g;
|
||||||
|
group.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'field-group-body';
|
||||||
|
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||||
|
if (cols > 1) {
|
||||||
|
body.classList.add('is-columns');
|
||||||
|
body.style.setProperty('--cols', String(cols));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
pop.appendChild(group);
|
||||||
|
pop.classList.add('has-groups');
|
||||||
|
container = body;
|
||||||
|
} else {
|
||||||
|
container = pop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
||||||
|
|
||||||
@@ -860,6 +1012,16 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
select.value = String(current ?? '');
|
select.value = String(current ?? '');
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
inputs.set(key, () => select.value);
|
inputs.set(key, () => select.value);
|
||||||
|
} else if (type === 'color') {
|
||||||
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
|
field.classList.add('color-field');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'color';
|
||||||
|
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
||||||
|
field.append(span, input);
|
||||||
|
inputs.set(key, () => input.value);
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -869,7 +1031,13 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
}
|
}
|
||||||
pop.appendChild(field);
|
if (spec?.help) {
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
@@ -915,6 +1083,7 @@ function renderLayout(list) {
|
|||||||
for (const tile of tiles) buildTile(tile);
|
for (const tile of tiles) buildTile(tile);
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
rebuildDividers();
|
rebuildDividers();
|
||||||
|
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── menus ──────────────────────────────────────────────────────────── */
|
/* ── menus ──────────────────────────────────────────────────────────── */
|
||||||
@@ -1026,6 +1195,179 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
|||||||
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||||||
|
|
||||||
|
const topbar = document.getElementById('topbar');
|
||||||
|
const topbarNotch = document.getElementById('topbar-notch');
|
||||||
|
const LS_MENU = 'proddash:menuHidden';
|
||||||
|
|
||||||
|
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
|
||||||
|
const topbarSizer = new ResizeObserver(() => {
|
||||||
|
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
|
||||||
|
});
|
||||||
|
topbarSizer.observe(topbar);
|
||||||
|
|
||||||
|
function setMenuHidden(hidden, { persist = true } = {}) {
|
||||||
|
document.body.classList.toggle('menu-hidden', hidden);
|
||||||
|
if (persist) {
|
||||||
|
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topbarNotch.addEventListener('click', () => {
|
||||||
|
setMenuHidden(!document.body.classList.contains('menu-hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||||
|
} catch { /* fine */ }
|
||||||
|
|
||||||
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
|
to the full width, and every column runs to the bottom of the screen: the
|
||||||
|
bottom-most tile of each column stretches to end exactly there. A small
|
||||||
|
tile (the clock) is never stretched into a ribbon — with a tile above it,
|
||||||
|
it drops to the bottom edge and the tile above grows into the gap; alone
|
||||||
|
in its columns, it stays exactly as it is. When nothing extends below the
|
||||||
|
viewport, scrolling is disabled. All of it is visual-only — the saved
|
||||||
|
layout is untouched and everything reverts on exit. */
|
||||||
|
|
||||||
|
const fsBtn = document.getElementById('fs-btn');
|
||||||
|
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
|
||||||
|
const FS_SMALL_ROWS = 2;
|
||||||
|
let menuHiddenBeforeFs = false;
|
||||||
|
|
||||||
|
function colsOverlap(a, b) {
|
||||||
|
return a.x < b.x + b.w && b.x < a.x + a.w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsSmall(tile) {
|
||||||
|
return tile.h <= FS_SMALL_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsAloneInColumns(tile) {
|
||||||
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsBottomMost(tile) {
|
||||||
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fullscreen row placement for every tile, given R rows on screen:
|
||||||
|
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
|
||||||
|
* small), plus the tile directly above a dropped small one, which grows
|
||||||
|
* into the space it left — stopping at anything else below it.
|
||||||
|
*/
|
||||||
|
function fullscreenRows(R) {
|
||||||
|
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
|
||||||
|
const dropped = [];
|
||||||
|
for (const tile of tiles) {
|
||||||
|
if (!fsBottomMost(tile)) continue;
|
||||||
|
if (!fsSmall(tile)) {
|
||||||
|
rows.get(tile.id).h = R - tile.y;
|
||||||
|
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
|
||||||
|
rows.get(tile.id).y = R - tile.h;
|
||||||
|
dropped.push(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const small of dropped) {
|
||||||
|
const gapTop = rows.get(small.id).y;
|
||||||
|
for (const above of tiles) {
|
||||||
|
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
|
||||||
|
if (above.y + above.h !== small.y) continue; // only a tile touching it
|
||||||
|
let limit = gapTop;
|
||||||
|
for (const other of tiles) {
|
||||||
|
if (other === above || other === small || !colsOverlap(above, other)) continue;
|
||||||
|
const oy = rows.get(other.id).y;
|
||||||
|
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
|
||||||
|
}
|
||||||
|
rows.get(above.id).h = Math.max(above.h, limit - above.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFullscreenFit() {
|
||||||
|
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
|
||||||
|
const m = cellMetrics();
|
||||||
|
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
|
||||||
|
const wrapH = gridwrap.clientHeight;
|
||||||
|
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
|
||||||
|
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
|
||||||
|
|
||||||
|
// x: the occupied columns stretch proportionally across the full width
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
|
||||||
|
|
||||||
|
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
|
||||||
|
if (layoutH > wrapH) {
|
||||||
|
// deeper than the screen: keep natural rows and scrolling
|
||||||
|
grid.style.gridAutoRows = '';
|
||||||
|
gridwrap.classList.remove('no-scroll');
|
||||||
|
for (const tile of tiles) applyRect(tile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing below the fold: lock scrolling and make R rows fill the height exactly
|
||||||
|
gridwrap.classList.add('no-scroll');
|
||||||
|
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
|
||||||
|
const cellH = (wrapH - (R - 1) * m.gap) / R;
|
||||||
|
grid.style.gridAutoRows = cellH + 'px';
|
||||||
|
|
||||||
|
const rows = fullscreenRows(R);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
if (!entry) continue;
|
||||||
|
const r = rows.get(tile.id);
|
||||||
|
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||||||
|
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
|
||||||
|
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFullscreenFit() {
|
||||||
|
grid.style.gridTemplateColumns = '';
|
||||||
|
grid.style.gridAutoRows = '';
|
||||||
|
gridwrap.classList.remove('no-scroll');
|
||||||
|
for (const tile of tiles) {
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fsBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const active = Boolean(document.fullscreenElement);
|
||||||
|
document.body.classList.toggle('fullscreen', active);
|
||||||
|
if (active) {
|
||||||
|
closeSettingsPopovers();
|
||||||
|
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||||
|
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||||
|
// let the collapsed menubar's space settle before measuring
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||||
|
} else {
|
||||||
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
|
clearFullscreenFit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The grid area's size settles late: the browser animates into fullscreen,
|
||||||
|
// then the menubar collapse animates. Re-fit whenever its size changes.
|
||||||
|
let fsRefit = 0;
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
if (!document.body.classList.contains('fullscreen')) return;
|
||||||
|
cancelAnimationFrame(fsRefit);
|
||||||
|
fsRefit = requestAnimationFrame(applyFullscreenFit);
|
||||||
|
}).observe(gridwrap);
|
||||||
|
|
||||||
/* ── live updates from the admin page ───────────────────────────────── */
|
/* ── live updates from the admin page ───────────────────────────────── */
|
||||||
|
|
||||||
/* The server broadcasts `modules-changed` on /api/events whenever admin
|
/* The server broadcasts `modules-changed` on /api/events whenever admin
|
||||||
|
|||||||
+221
-12
@@ -39,11 +39,71 @@ button { font-family: inherit; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
height: 46px;
|
||||||
|
padding: 0 12px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
|
}
|
||||||
|
/* collapsed by its notch (and automatically in fullscreen) */
|
||||||
|
body.menu-hidden #topbar {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The menubar's show/hide handle IS the logo: a translucent accent pill
|
||||||
|
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
|
||||||
|
background colour. It sits at the left — never screen-centre, where a
|
||||||
|
tile's own (centred) tab lands once both bars are hidden. When the bar
|
||||||
|
hides, the pill shrinks to a slim lip at the very top edge, clear of a
|
||||||
|
top-row tile's status dot and title. */
|
||||||
|
.brand-spacer { visibility: hidden; }
|
||||||
|
|
||||||
|
#topbar-notch {
|
||||||
|
position: fixed;
|
||||||
|
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
|
||||||
|
8px past the bar's bottom edge; the logo sits centred in the bar part */
|
||||||
|
top: 0;
|
||||||
|
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
|
||||||
|
height: calc(var(--topbar-h, 46px) + 8px);
|
||||||
|
min-width: 60px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: var(--topbar-h, 46px);
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 60;
|
||||||
|
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
#topbar-notch .accent { color: var(--bg); } /* the cutout */
|
||||||
|
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
|
||||||
|
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
|
||||||
|
body.menu-hidden #topbar-notch {
|
||||||
|
height: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
font-size: 0;
|
||||||
|
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
|
||||||
|
body.menu-hidden #topbar-notch::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#brand {
|
#brand {
|
||||||
@@ -207,9 +267,15 @@ button { font-family: inherit; }
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile.dragging .tile-head { cursor: grabbing; }
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
.tile.head-hidden .tile-head { display: none; }
|
/* hidden = collapsed, not removed, so the notch toggle animates */
|
||||||
|
.tile.head-hidden .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -305,7 +371,7 @@ button { font-family: inherit; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease, top 0.15s ease;
|
transition: opacity 0.15s ease, top 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile:hover .tile-notch { opacity: 0.6; }
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
.tile .tile-notch:hover { opacity: 1; }
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
@@ -329,30 +395,76 @@ button { font-family: inherit; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-resize {
|
.tile-resize,
|
||||||
|
.tile-resize-left {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
cursor: nwse-resize;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
opacity: 0.6;
|
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.tile-resize:hover { opacity: 1; color: var(--text); }
|
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||||
.tile-resize::after {
|
.tile-resize-left { left: 0; cursor: nesw-resize; }
|
||||||
|
.tile:hover .tile-resize,
|
||||||
|
.tile:hover .tile-resize-left { opacity: 0.6; }
|
||||||
|
.tile .tile-resize:hover,
|
||||||
|
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
|
||||||
|
.tile-resize::after,
|
||||||
|
.tile-resize-left::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-right: 2px solid currentColor;
|
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
.tile-resize::after {
|
||||||
|
right: 4px;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
border-radius: 0 0 3px 0;
|
border-radius: 0 0 3px 0;
|
||||||
}
|
}
|
||||||
|
.tile-resize-left::after {
|
||||||
|
left: 4px;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-radius: 0 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
|
||||||
|
sits between the two corner handles */
|
||||||
|
.tile-resize-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile-resize-bottom::after {
|
||||||
|
content: "";
|
||||||
|
width: 44px;
|
||||||
|
max-width: 60%;
|
||||||
|
height: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
|
.tile .tile-resize-bottom:hover,
|
||||||
|
.tile.resizing .tile-resize-bottom { opacity: 1; }
|
||||||
|
.tile .tile-resize-bottom:hover::after,
|
||||||
|
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
|
||||||
|
|
||||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
@@ -389,6 +501,18 @@ button { font-family: inherit; }
|
|||||||
they're rebuilt for the new geometry on release. */
|
they're rebuilt for the new geometry on release. */
|
||||||
#grid.no-dividers .tile-divider { display: none; }
|
#grid.no-dividers .tile-divider { display: none; }
|
||||||
|
|
||||||
|
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
||||||
|
editing affordances (corner handles, seam pills) are parked. */
|
||||||
|
body.fullscreen .tile-resize,
|
||||||
|
body.fullscreen .tile-resize-left,
|
||||||
|
body.fullscreen .tile-resize-bottom,
|
||||||
|
body.fullscreen .tile-divider,
|
||||||
|
body.fullscreen #admin-link { display: none; }
|
||||||
|
body.fullscreen #grid { padding-bottom: 0; }
|
||||||
|
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||||
|
|
||||||
/* status message a module can show while degraded (also used by the shell
|
/* status message a module can show while degraded (also used by the shell
|
||||||
when a module fails to load) */
|
when a module fails to load) */
|
||||||
.tile-msg {
|
.tile-msg {
|
||||||
@@ -423,6 +547,70 @@ button { font-family: inherit; }
|
|||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A popover with grouped settings needs room for its columns; it still
|
||||||
|
never grows past the tile (max-width above). */
|
||||||
|
.tile-settings.has-groups {
|
||||||
|
min-width: min(540px, calc(100% - 12px));
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tile-settings details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
|
||||||
|
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
.tile-settings .field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 3px 14px;
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
|
||||||
|
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@container (max-width: 420px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@container (max-width: 260px) {
|
||||||
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help,
|
||||||
|
.tile-settings .field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tile-settings .field-group-help { margin: -2px 0 6px; }
|
||||||
|
.tile-settings .field-help { margin-top: 1px; }
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -456,6 +644,27 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* color fields: label on the left, swatch on the right */
|
||||||
|
.field.color-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.field.color-field > span { flex: 1; min-width: 0; }
|
||||||
|
.field.color-field input[type="color"] {
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.field.color-field input[type="color"]:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.field.color-field .field-help { flex-basis: 100%; }
|
||||||
|
|
||||||
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
|
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
|
||||||
|
|
||||||
#modal-backdrop {
|
#modal-backdrop {
|
||||||
@@ -544,5 +753,5 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
#topbar { padding: 6px 8px; flex-wrap: wrap; }
|
#topbar { padding: 0 8px; gap: 6px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
*
|
*
|
||||||
* Shell server: serves the dashboard client from public/, discovers modules
|
* Shell server: serves the dashboard client from public/, discovers modules
|
||||||
* in modules/, mounts each enabled module's server routes under
|
* in modules/, mounts each enabled module's server routes under
|
||||||
* /api/modules/<id>/, and stores server-side config + named layouts under
|
* /api/modules/<id>/, and stores server-side module config + named layouts
|
||||||
* config/.
|
* in a per-machine data directory (see DATA_DIR below) so that updating the
|
||||||
|
* checkout never loses them.
|
||||||
*
|
*
|
||||||
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
||||||
* Configure via config/proddash.json or env vars:
|
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||||
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -19,12 +22,60 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const { spawn, execFileSync } = require('child_process');
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||||
const CONFIG_DIR = path.join(ROOT, 'config');
|
const BUNDLED_MODULES_DIR = path.join(ROOT, 'modules'); // modules that ship with the checkout
|
||||||
const MODULES_DIR = path.join(ROOT, 'modules');
|
/** Checked-in defaults (proddash.json) — and where runtime state used to live. */
|
||||||
const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json');
|
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
|
||||||
|
|
||||||
|
/* ── data directory ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app writes — module config (connection settings, API keys,
|
||||||
|
* enabled flags) and named layouts — lives in a per-machine data directory
|
||||||
|
* OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from
|
||||||
|
* main therefore never touches it, and nothing has to be re-entered in /admin.
|
||||||
|
*
|
||||||
|
* PRODDASH_DATA_DIR overrides the location. A relative path resolves
|
||||||
|
* against the app folder ("config" = the old in-repo spot).
|
||||||
|
* default Windows %APPDATA%\ProdDash
|
||||||
|
* macOS ~/Library/Application Support/ProdDash
|
||||||
|
* other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
|
||||||
|
*
|
||||||
|
* If the directory cannot be created or written, the server says so and
|
||||||
|
* falls back to the in-repo config/ folder rather than refusing to start.
|
||||||
|
*/
|
||||||
|
function defaultDataDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||||
|
}
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK);
|
||||||
|
return dir;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`);
|
||||||
|
return REPO_CONFIG_DIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_DIR = resolveDataDir();
|
||||||
|
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
|
||||||
|
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
|
||||||
|
const INSTALLED_MODULES_DIR = path.join(DATA_DIR, 'modules'); // modules installed from the admin page
|
||||||
|
|
||||||
/* ── config files ───────────────────────────────────────────────────── */
|
/* ── config files ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -43,9 +94,107 @@ function writeJson(file, value) {
|
|||||||
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
|
/**
|
||||||
|
* One-time move-in. Earlier versions kept modules.json and layouts/ inside the
|
||||||
|
* repo's config/ folder; if the data directory has none yet and those files
|
||||||
|
* exist, copy them across (the originals are left alone).
|
||||||
|
*/
|
||||||
|
function migrateLegacyState() {
|
||||||
|
if (DATA_DIR === REPO_CONFIG_DIR) return;
|
||||||
|
const moved = [];
|
||||||
|
const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json');
|
||||||
|
if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(oldModules, MODULES_CONFIG_PATH);
|
||||||
|
moved.push('modules.json');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts');
|
||||||
|
let oldFiles = [];
|
||||||
|
try {
|
||||||
|
oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* none to move */ }
|
||||||
|
let haveLayouts = false;
|
||||||
|
try {
|
||||||
|
haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json'));
|
||||||
|
} catch { /* no layouts dir yet */ }
|
||||||
|
if (oldFiles.length && !haveLayouts) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
|
||||||
|
for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f));
|
||||||
|
moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moved.length) {
|
||||||
|
console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
migrateLegacyState();
|
||||||
|
|
||||||
|
/** Shell settings: checked-in defaults, then this machine's overrides on top. */
|
||||||
|
const shellConfig = {
|
||||||
|
...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}),
|
||||||
|
...readJson(path.join(DATA_DIR, 'proddash.json'), {}),
|
||||||
|
};
|
||||||
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
||||||
|
|
||||||
|
/* ── shell version & version ranges ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** The shell's own version, from package.json. Modules declare the range
|
||||||
|
they need in their manifest ("proddash": ">=1.1.0"). */
|
||||||
|
const SHELL_VERSION = String(readJson(path.join(ROOT, 'package.json'), {}).version || '0.0.0');
|
||||||
|
|
||||||
|
function parseVersion(v) {
|
||||||
|
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -1 / 0 / 1 like a comparator; unparseable versions sort lowest. */
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = parseVersion(a) || [-1, 0, 0];
|
||||||
|
const pb = parseVersion(b) || [-1, 0, 0];
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does `version` satisfy `range`? Ranges are deliberately small: "" or "*"
|
||||||
|
* (anything), "1.2.0" (exact), ">=1.2.0", ">1.2.0", "<=…", "<…",
|
||||||
|
* "^1.2.0" (same major, at least that), "~1.2.0" (same minor, at least
|
||||||
|
* that), and space-separated combinations such as ">=1.1.0 <2.0.0".
|
||||||
|
*/
|
||||||
|
function satisfiesVersion(version, range) {
|
||||||
|
const r = String(range || '').trim();
|
||||||
|
if (!r || r === '*') return true;
|
||||||
|
const v = parseVersion(version);
|
||||||
|
if (!v) return false;
|
||||||
|
return r.split(/[\s,]+/).filter(Boolean).every((part) => {
|
||||||
|
const m = part.match(/^(>=|<=|>|<|=|\^|~)?v?(\d+)\.(\d+)(?:\.(\d+))?$/);
|
||||||
|
if (!m) return false;
|
||||||
|
const t = [Number(m[2]), Number(m[3]), Number(m[4] || 0)];
|
||||||
|
const c = compareVersions(v.join('.'), t.join('.'));
|
||||||
|
switch (m[1] || '=') {
|
||||||
|
case '>=': return c >= 0;
|
||||||
|
case '>': return c > 0;
|
||||||
|
case '<=': return c <= 0;
|
||||||
|
case '<': return c < 0;
|
||||||
|
case '^': return v[0] === t[0] && c >= 0;
|
||||||
|
case '~': return v[0] === t[0] && v[1] === t[1] && c >= 0;
|
||||||
|
default: return c === 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementNote(range) {
|
||||||
|
return `Requires ProdDash ${range} — this is ${SHELL_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
||||||
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
||||||
|
|
||||||
@@ -54,18 +203,19 @@ let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
|||||||
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
||||||
const manifests = new Map();
|
const manifests = new Map();
|
||||||
|
|
||||||
function discoverModules() {
|
/** Read every valid manifest in one modules folder. */
|
||||||
manifests.clear();
|
function readManifests(baseDir, source) {
|
||||||
|
const out = [];
|
||||||
let dirs = [];
|
let dirs = [];
|
||||||
try {
|
try {
|
||||||
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
dirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
} catch {
|
} catch {
|
||||||
return; // no modules/ folder yet — the shell still runs
|
return out; // folder missing — fine
|
||||||
}
|
}
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const manifestPath = path.join(MODULES_DIR, dir, 'module.json');
|
const manifestPath = path.join(baseDir, dir, 'module.json');
|
||||||
const man = readJson(manifestPath, null);
|
const man = readJson(manifestPath, null);
|
||||||
if (!man || typeof man !== 'object') {
|
if (!man || typeof man !== 'object') {
|
||||||
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
||||||
@@ -80,9 +230,48 @@ function discoverModules() {
|
|||||||
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
man.dir = path.join(MODULES_DIR, dir);
|
man.dir = path.join(baseDir, dir);
|
||||||
manifests.set(dir, man);
|
man.source = source;
|
||||||
|
man.requires = String(man.proddash || '').trim();
|
||||||
|
// A module built for a newer shell is listed (so admin can explain) but never loaded.
|
||||||
|
man.incompatible = satisfiesVersion(SHELL_VERSION, man.requires) ? '' : requirementNote(man.requires);
|
||||||
|
out.push(man);
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modules come from two places: the ones bundled with this checkout
|
||||||
|
* (modules/) and the ones installed from the admin page (the data
|
||||||
|
* directory's modules/). Same id in both → the newer version runs; a
|
||||||
|
* downloaded copy the bundled one has since overtaken is removed. A bundled
|
||||||
|
* module the admin page "uninstalled" stays on disk (it belongs to the app
|
||||||
|
* folder) but is left out here.
|
||||||
|
*/
|
||||||
|
function discoverModules() {
|
||||||
|
manifests.clear();
|
||||||
|
const bundled = new Map(readManifests(BUNDLED_MODULES_DIR, 'bundled').map((m) => [m.id, m]));
|
||||||
|
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||||
|
for (const [id, man] of bundled) {
|
||||||
|
if (isUninstalled(id)) continue;
|
||||||
|
const copy = installed.get(id);
|
||||||
|
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the copy wins, below
|
||||||
|
if (copy) {
|
||||||
|
console.log(`[proddash] ${id}: bundled v${man.version} supersedes the installed v${copy.version} — removing that copy`);
|
||||||
|
try { fs.rmSync(copy.dir, { recursive: true, force: true }); } catch { /* it just stays unused */ }
|
||||||
|
installed.delete(id);
|
||||||
|
}
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
for (const [id, man] of installed) {
|
||||||
|
if (manifests.has(id)) continue;
|
||||||
|
man.bundledVersion = String(bundled.get(id)?.version || '');
|
||||||
|
manifests.set(id, man);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUninstalled(id) {
|
||||||
|
return moduleState(id).uninstalled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleState(id) {
|
function moduleState(id) {
|
||||||
@@ -94,6 +283,12 @@ function isEnabled(id) {
|
|||||||
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enabled and runnable on this shell version — what dashboards may load. */
|
||||||
|
function isActive(id) {
|
||||||
|
const man = manifests.get(id);
|
||||||
|
return Boolean(man) && isEnabled(id) && !man.incompatible;
|
||||||
|
}
|
||||||
|
|
||||||
/** Admin config for a module: schema defaults overlaid with stored values. */
|
/** Admin config for a module: schema defaults overlaid with stored values. */
|
||||||
function effectiveConfig(id) {
|
function effectiveConfig(id) {
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
@@ -167,6 +362,10 @@ function mountModule(id) {
|
|||||||
if (!man) return;
|
if (!man) return;
|
||||||
const entry = { routes: [], handle: null, error: '' };
|
const entry = { routes: [], handle: null, error: '' };
|
||||||
mounted.set(id, entry);
|
mounted.set(id, entry);
|
||||||
|
if (man.incompatible) {
|
||||||
|
entry.error = man.incompatible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!man.server) return;
|
if (!man.server) return;
|
||||||
const log = makeLog(id);
|
const log = makeLog(id);
|
||||||
try {
|
try {
|
||||||
@@ -309,6 +508,7 @@ async function clientManifest(id) {
|
|||||||
minSize: man.minSize || { w: 1, h: 1 },
|
minSize: man.minSize || { w: 1, h: 1 },
|
||||||
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
|
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
@@ -341,6 +541,385 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 25000).unref();
|
}, 25000).unref();
|
||||||
|
|
||||||
|
/* ── the repo: module catalog and shell updates ──────────────────────── */
|
||||||
|
|
||||||
|
/* The admin page installs modules from, and updates the shell against, the
|
||||||
|
ProdDash repository on GitHub — one small tar.gz of the branch, fetched at
|
||||||
|
most every few minutes, parsed here with no dependencies. Nothing is
|
||||||
|
contacted unless someone opens the admin page or the periodic update
|
||||||
|
check runs; every failure is recorded and shown, never thrown at a page. */
|
||||||
|
|
||||||
|
function parseRepoSetting(value, fallbackBranch) {
|
||||||
|
const m = String(value || '').trim().match(/^([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#([\w./-]+))?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { owner: m[1], repo: m[2], branch: m[3] || fallbackBranch || 'main' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||||
|
/** Where branch archives come from; tests point this at a local mock. */
|
||||||
|
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
|
||||||
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
|
function tarballUrl() {
|
||||||
|
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(buf) {
|
||||||
|
try { return buf ? JSON.parse(buf.toString('utf8')) : null; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A relative path that can only land inside the folder it is joined to. */
|
||||||
|
function safeRelPath(p) {
|
||||||
|
if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0')) return false;
|
||||||
|
return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — a minimal tar reader: regular files, GNU long names, pax headers — */
|
||||||
|
|
||||||
|
function cstr(buf, start, len) {
|
||||||
|
const s = buf.subarray(start, start + len);
|
||||||
|
const nul = s.indexOf(0);
|
||||||
|
return s.subarray(0, nul === -1 ? len : nul).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pax records look like "27 path=some/long/name\n" (length counts itself). */
|
||||||
|
function parsePaxRecords(buf) {
|
||||||
|
const out = {};
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const sp = buf.indexOf(0x20, pos);
|
||||||
|
if (sp === -1) break;
|
||||||
|
const len = parseInt(buf.subarray(pos, sp).toString(), 10);
|
||||||
|
if (!Number.isFinite(len) || len <= 0) break;
|
||||||
|
const rec = buf.subarray(sp + 1, pos + len - 1).toString('utf8');
|
||||||
|
const eq = rec.indexOf('=');
|
||||||
|
if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1);
|
||||||
|
pos += len;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTar(buf) {
|
||||||
|
const files = new Map();
|
||||||
|
let comment = '';
|
||||||
|
let pendingName = null;
|
||||||
|
let off = 0;
|
||||||
|
while (off + 512 <= buf.length) {
|
||||||
|
const h = buf.subarray(off, off + 512);
|
||||||
|
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||||
|
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||||
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||||
|
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||||
|
const shortName = prefix ? `${prefix}/${cstr(h, 0, 100)}` : cstr(h, 0, 100);
|
||||||
|
const data = buf.subarray(off + 512, off + 512 + size);
|
||||||
|
off += 512 + Math.ceil(size / 512) * 512;
|
||||||
|
if (type === 'L') { pendingName = cstr(data, 0, size); continue; } // GNU long name
|
||||||
|
if (type === 'g' || type === 'x') { // pax headers
|
||||||
|
const rec = parsePaxRecords(data);
|
||||||
|
if (rec.comment && !comment) comment = String(rec.comment).trim(); // GitHub: the commit sha
|
||||||
|
if (type === 'x' && rec.path) pendingName = rec.path;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = pendingName || shortName;
|
||||||
|
pendingName = null;
|
||||||
|
if (type === '0') files.set(name, Buffer.from(data));
|
||||||
|
}
|
||||||
|
return { files, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the branch archive → { files: Map<relative path, Buffer>, sha }. */
|
||||||
|
async function fetchArchive() {
|
||||||
|
const res = await fetch(tarballUrl(), {
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
||||||
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const i = p.indexOf('/');
|
||||||
|
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
||||||
|
}
|
||||||
|
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What we last learned about the repo. */
|
||||||
|
let remote = { checkedAt: 0, checking: null, error: '', sha: '', version: '', modules: new Map(), files: null };
|
||||||
|
|
||||||
|
async function refreshRemote(force = false) {
|
||||||
|
const fresh = remote.checkedAt && Date.now() - remote.checkedAt < REMOTE_TTL_MS;
|
||||||
|
if (!force && fresh && !remote.error) return remote;
|
||||||
|
if (remote.checking) return remote.checking;
|
||||||
|
const job = (async () => {
|
||||||
|
try {
|
||||||
|
const { files, sha } = await fetchArchive();
|
||||||
|
const pkg = safeJson(files.get('package.json'));
|
||||||
|
const modules = new Map();
|
||||||
|
for (const [p, data] of files) {
|
||||||
|
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||||
|
entry.files.set(m[2], data);
|
||||||
|
modules.set(m[1], entry);
|
||||||
|
}
|
||||||
|
for (const [id, entry] of modules) {
|
||||||
|
const man = safeJson(entry.files.get('module.json'));
|
||||||
|
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||||
|
modules.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.manifest = { ...man, id };
|
||||||
|
}
|
||||||
|
remote = { checkedAt: Date.now(), checking: null, error: '', sha, version: String(pkg?.version || ''), modules, files };
|
||||||
|
} catch (err) {
|
||||||
|
remote = { ...remote, checkedAt: Date.now(), checking: null, error: err?.message || String(err) };
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
})();
|
||||||
|
remote.checking = job;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — this checkout — */
|
||||||
|
|
||||||
|
function git(args, timeout = 15000) {
|
||||||
|
// fileMode=false: a chmod (e.g. making the launcher executable) is not a local change
|
||||||
|
return execFileSync('git', ['-c', 'core.fileMode=false', ...args], { cwd: ROOT, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localGitInfo() {
|
||||||
|
if (!fs.existsSync(path.join(ROOT, '.git'))) return { isGit: false, sha: '', branch: '', dirty: false, error: '' };
|
||||||
|
try {
|
||||||
|
const porcelain = git(['status', '--porcelain']).split('\n').filter((l) => l && !l.startsWith('??'));
|
||||||
|
return { isGit: true, sha: git(['rev-parse', 'HEAD']), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), dirty: porcelain.length > 0, error: '' };
|
||||||
|
} catch (err) {
|
||||||
|
return { isGit: true, sha: '', branch: '', dirty: false, error: (err?.message || String(err)).split('\n')[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellStatus() {
|
||||||
|
const local = localGitInfo();
|
||||||
|
const versionCmp = remote.version ? compareVersions(remote.version, SHELL_VERSION) : 0;
|
||||||
|
const versionBehind = versionCmp > 0;
|
||||||
|
const sameCommit = !local.sha || !remote.sha || local.sha.startsWith(remote.sha) || remote.sha.startsWith(local.sha);
|
||||||
|
const commitsBehind = !sameCommit && versionCmp >= 0;
|
||||||
|
let blocker = '';
|
||||||
|
if (local.isGit) {
|
||||||
|
if (local.error) blocker = `git is not usable here (${local.error})`;
|
||||||
|
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
||||||
|
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
||||||
|
}
|
||||||
|
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
|
||||||
|
return {
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
|
branch: REPO.branch,
|
||||||
|
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
||||||
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
|
versionBehind,
|
||||||
|
method: local.isGit ? 'git' : 'archive',
|
||||||
|
updateBlocker: blocker,
|
||||||
|
changesUrl: local.sha && remote.sha
|
||||||
|
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
||||||
|
: `${gh}/commits/${REPO.branch}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — the catalog: what could be installed or updated — */
|
||||||
|
|
||||||
|
function catalogView() {
|
||||||
|
const available = [];
|
||||||
|
const updates = [];
|
||||||
|
const hiddenBundled = readManifests(BUNDLED_MODULES_DIR, 'bundled').filter((m) => isUninstalled(m.id));
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [id, entry] of remote.modules) {
|
||||||
|
const man = entry.manifest;
|
||||||
|
seen.add(id);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
const compatible = satisfiesVersion(SHELL_VERSION, requires);
|
||||||
|
const base = {
|
||||||
|
id,
|
||||||
|
name: String(man.name || id),
|
||||||
|
version: String(man.version || ''),
|
||||||
|
description: String(man.description || ''),
|
||||||
|
requires,
|
||||||
|
compatible,
|
||||||
|
reason: compatible ? '' : requirementNote(requires),
|
||||||
|
};
|
||||||
|
const installed = manifests.get(id);
|
||||||
|
if (installed) {
|
||||||
|
if (compareVersions(man.version, installed.version) > 0) {
|
||||||
|
updates.push({ ...base, installedVersion: String(installed.version || ''), source: 'repo' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hidden = hiddenBundled.find((m) => m.id === id);
|
||||||
|
const useBundled = hidden && compareVersions(hidden.version, man.version) >= 0;
|
||||||
|
available.push({ ...base, source: useBundled ? 'bundled' : 'repo' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of hiddenBundled) {
|
||||||
|
if (seen.has(m.id)) continue;
|
||||||
|
available.push({
|
||||||
|
id: m.id, name: String(m.name || m.id), version: String(m.version || ''), description: String(m.description || ''),
|
||||||
|
requires: m.requires, compatible: !m.incompatible, reason: m.incompatible, source: 'bundled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
|
return { available: available.sort(byName), updates: updates.sort(byName) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install (or update) a module from the repo, or bring back a bundled one. */
|
||||||
|
async function installModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
await refreshRemote(false);
|
||||||
|
const remoteEntry = remote.modules.get(id) || null;
|
||||||
|
const bundledMan = readManifests(BUNDLED_MODULES_DIR, 'bundled').find((m) => m.id === id) || null;
|
||||||
|
const installedMan = readManifests(INSTALLED_MODULES_DIR, 'installed').find((m) => m.id === id) || null;
|
||||||
|
if (!remoteEntry && !bundledMan && !installedMan) {
|
||||||
|
throw httpError(404, remote.error
|
||||||
|
? `"${id}" is not here and the repo could not be reached: ${remote.error}`
|
||||||
|
: `"${id}" is not in ${REPO.owner}/${REPO.repo}.`);
|
||||||
|
}
|
||||||
|
// End up with the newest copy: what the repo offers vs what is already here.
|
||||||
|
const haveVersion = installedMan?.version || bundledMan?.version || '';
|
||||||
|
const download = Boolean(remoteEntry) && (!(bundledMan || installedMan) || compareVersions(remoteEntry.manifest.version, haveVersion) > 0);
|
||||||
|
const man = download ? remoteEntry.manifest : (installedMan || bundledMan);
|
||||||
|
const requires = String(man.proddash || '').trim();
|
||||||
|
if (!satisfiesVersion(SHELL_VERSION, requires)) {
|
||||||
|
throw httpError(409, `${man.name || id} ${requirementNote(requires).replace('Requires', 'requires')}. Update ProdDash first.`);
|
||||||
|
}
|
||||||
|
if (download) {
|
||||||
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
|
if (!safeRelPath(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
unmountModule(id);
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, dest);
|
||||||
|
}
|
||||||
|
const state = { ...moduleState(id) };
|
||||||
|
delete state.uninstalled;
|
||||||
|
modulesConfig[id] = state;
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
remountModule(id);
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
const now = manifests.get(id);
|
||||||
|
console.log(`[proddash] ${download ? 'installed' : 'restored'} module "${id}" v${now?.version || '?'} (${now?.source || 'unknown'})`);
|
||||||
|
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||||
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
|
later reinstall picks them up again. */
|
||||||
|
function uninstallModule(id) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw httpError(400, 'Bad module id.');
|
||||||
|
const installedDir = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
const hadInstalled = fs.existsSync(path.join(installedDir, 'module.json'));
|
||||||
|
const hasBundled = fs.existsSync(path.join(BUNDLED_MODULES_DIR, id, 'module.json'));
|
||||||
|
if (!manifests.has(id) && !hadInstalled && !hasBundled) throw httpError(404, `No module "${id}" is installed.`);
|
||||||
|
unmountModule(id);
|
||||||
|
if (hadInstalled) fs.rmSync(installedDir, { recursive: true, force: true });
|
||||||
|
modulesConfig[id] = { ...moduleState(id), uninstalled: true };
|
||||||
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
|
discoverModules();
|
||||||
|
broadcastShellEvent('modules-changed', { id });
|
||||||
|
console.log(`[proddash] uninstalled module "${id}"${hadInstalled ? ' (files removed)' : ''}${hasBundled ? ' (bundled copy hidden)' : ''}`);
|
||||||
|
return { id, removedFiles: hadInstalled, bundled: hasBundled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — updating the shell itself — */
|
||||||
|
|
||||||
|
function copyTree(src, dst) {
|
||||||
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, e.name);
|
||||||
|
const d = path.join(dst, e.name);
|
||||||
|
if (e.isDirectory()) copyTree(s, d);
|
||||||
|
else fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the app folder up to the repo's branch. A git checkout gets
|
||||||
|
* `git pull --ff-only`; anything else has the archive unpacked over it
|
||||||
|
* (runtime state and .git are never touched). The caller restarts.
|
||||||
|
*/
|
||||||
|
async function updateShell() {
|
||||||
|
await refreshRemote(true);
|
||||||
|
if (remote.error) throw httpError(502, `Couldn't reach ${REPO.owner}/${REPO.repo}: ${remote.error}`);
|
||||||
|
const status = shellStatus();
|
||||||
|
if (status.updateBlocker) throw httpError(409, `Can't update from here: ${status.updateBlocker}.`);
|
||||||
|
if (!status.updateAvailable) throw httpError(409, 'Already up to date.');
|
||||||
|
if (status.local.isGit) {
|
||||||
|
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||||
|
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||||
|
} else {
|
||||||
|
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||||
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
for (const [rel, data] of remote.files) {
|
||||||
|
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||||
|
const target = path.join(staging, rel);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, data);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||||
|
}
|
||||||
|
copyTree(staging, ROOT);
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||||
|
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||||
|
* own replacement, which waits for this port to free up.
|
||||||
|
*/
|
||||||
|
function restartServer(reason) {
|
||||||
|
console.log(`[proddash] ${reason} — restarting ProdDash`);
|
||||||
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (!process.env.PRODDASH_LAUNCHER) {
|
||||||
|
try {
|
||||||
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||||
|
cwd: process.cwd(), env: process.env, detached: true, stdio: 'inherit',
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[proddash] could not start the new ProdDash — start it by hand:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(75);
|
||||||
|
};
|
||||||
|
server.close(finish);
|
||||||
|
setTimeout(finish, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||||
@@ -390,8 +969,12 @@ function adminModuleView(id) {
|
|||||||
version: String(man.version || ''),
|
version: String(man.version || ''),
|
||||||
description: String(man.description || ''),
|
description: String(man.description || ''),
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
source: man.source || 'bundled',
|
||||||
|
requires: man.requires || '',
|
||||||
|
incompatible: man.incompatible || '',
|
||||||
enabled: isEnabled(id),
|
enabled: isEnabled(id),
|
||||||
configSchema: man.configSchema || {},
|
configSchema: man.configSchema || {},
|
||||||
|
configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {},
|
||||||
config: values,
|
config: values,
|
||||||
passwordSet,
|
passwordSet,
|
||||||
mountError: entry?.error || '',
|
mountError: entry?.error || '',
|
||||||
@@ -412,8 +995,14 @@ function applyConfigPatch(id, patch) {
|
|||||||
value = current[key] || '';
|
value = current[key] || '';
|
||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean') value = Boolean(value);
|
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
||||||
else value = value === undefined || value === null ? '' : String(value);
|
else if (type === 'endpoint') {
|
||||||
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
|
value = {
|
||||||
|
host: String(src.host ?? '').trim(),
|
||||||
|
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
|
||||||
|
};
|
||||||
|
} else value = value === undefined || value === null ? '' : String(value);
|
||||||
next[key] = value;
|
next[key] = value;
|
||||||
}
|
}
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
||||||
@@ -447,10 +1036,51 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
authRequired: Boolean(ADMIN_PASSCODE),
|
authRequired: Boolean(ADMIN_PASSCODE),
|
||||||
authed: true,
|
authed: true,
|
||||||
|
version: SHELL_VERSION,
|
||||||
|
dataDir: DATA_DIR,
|
||||||
modules: [...manifests.keys()].sort().map(adminModuleView),
|
modules: [...manifests.keys()].sort().map(adminModuleView),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/catalog' && req.method === 'GET') {
|
||||||
|
if (!isAuthed(req)) return sendJson(res, 401, { authRequired: true, authed: false });
|
||||||
|
const refresh = new URL(req.url, 'http://proddash.invalid').searchParams.get('refresh') === '1';
|
||||||
|
await refreshRemote(refresh);
|
||||||
|
return sendJson(res, 200, { shell: shellStatus(), ...catalogView() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/api/admin/update' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
const result = await updateShell();
|
||||||
|
sendJson(res, 200, { ok: true, ...result, restarting: true });
|
||||||
|
setTimeout(() => restartServer(`updated ${result.from} → ${result.to || 'latest'} via ${result.method}`), 400);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||||
|
if (install && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...(await installModule(decodeURIComponent(install[1]))) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = urlPath.match(/^\/api\/admin\/modules\/([^/]+)$/);
|
||||||
|
if (remove && req.method === 'DELETE') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...uninstallModule(decodeURIComponent(remove[1])) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
||||||
if (m && req.method === 'PUT') {
|
if (m && req.method === 'PUT') {
|
||||||
const id = decodeURIComponent(m[1]);
|
const id = decodeURIComponent(m[1]);
|
||||||
@@ -462,15 +1092,19 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
if (m[2] === 'config') {
|
if (m[2] === 'config') {
|
||||||
applyConfigPatch(id, body.config);
|
applyConfigPatch(id, body.config);
|
||||||
remountModule(id); // config changes take effect without a manual restart
|
|
||||||
} else {
|
} else {
|
||||||
const enabled = Boolean(body.enabled);
|
modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled };
|
|
||||||
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
||||||
remountModule(id); // mounts when enabled, unmounts (only) otherwise
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` });
|
||||||
|
}
|
||||||
|
// Config changes take effect without a manual restart; a toggle mounts when
|
||||||
|
// enabled and unmounts (only) otherwise.
|
||||||
|
remountModule(id);
|
||||||
broadcastShellEvent('modules-changed', { id });
|
broadcastShellEvent('modules-changed', { id });
|
||||||
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
||||||
}
|
}
|
||||||
@@ -478,9 +1112,7 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── named layouts (JSON files under config/layouts/) ───────────────── */
|
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
|
||||||
|
|
||||||
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
|
|
||||||
|
|
||||||
/** Names double as file names — keep them boring on purpose. */
|
/** Names double as file names — keep them boring on purpose. */
|
||||||
function layoutNameOk(name) {
|
function layoutNameOk(name) {
|
||||||
@@ -558,7 +1190,11 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
}
|
}
|
||||||
const layout = sanitizeLayout(body.layout);
|
const layout = sanitizeLayout(body.layout);
|
||||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||||
|
try {
|
||||||
writeJson(layoutPath(name), layout);
|
writeJson(layoutPath(name), layout);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||||
|
}
|
||||||
return sendJson(res, 200, { ok: true, name });
|
return sendJson(res, 200, { ok: true, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,6 +1240,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||||
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
||||||
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
||||||
|
if (manifests.get(id).incompatible) return sendJson(res, 409, { error: manifests.get(id).incompatible });
|
||||||
const entry = mounted.get(id);
|
const entry = mounted.get(id);
|
||||||
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
||||||
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
||||||
@@ -706,7 +1343,7 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
|
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||||
.then((list) => sendJson(res, 200, { modules: list }))
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[proddash] /api/modules failed:', err);
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
@@ -773,7 +1410,7 @@ function handleRequest(req, res) {
|
|||||||
if (assetMatch) {
|
if (assetMatch) {
|
||||||
const id = decodeURIComponent(assetMatch[1]);
|
const id = decodeURIComponent(assetMatch[1]);
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
if (!man || !isEnabled(id)) {
|
if (!man || !isActive(id)) {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
return res.end('Not found');
|
return res.end('Not found');
|
||||||
}
|
}
|
||||||
@@ -804,8 +1441,8 @@ server.requestTimeout = 0;
|
|||||||
discoverModules();
|
discoverModules();
|
||||||
mountAllModules();
|
mountAllModules();
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.on('listening', () => {
|
||||||
console.log('ProdDash running');
|
console.log('ProdDash ' + SHELL_VERSION);
|
||||||
console.log(' Local : http://localhost:' + PORT);
|
console.log(' Local : http://localhost:' + PORT);
|
||||||
for (const ifaces of Object.values(os.networkInterfaces())) {
|
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||||
for (const iface of ifaces || []) {
|
for (const iface of ifaces || []) {
|
||||||
@@ -816,10 +1453,34 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||||||
}
|
}
|
||||||
const ids = [...manifests.keys()];
|
const ids = [...manifests.keys()];
|
||||||
console.log(ids.length
|
console.log(ids.length
|
||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
||||||
: ' Modules : none installed');
|
: ' Modules : none installed');
|
||||||
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
||||||
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
let listenTries = 0;
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE' && listenTries < 30) {
|
||||||
|
listenTries += 1;
|
||||||
|
if (listenTries === 1) console.log(`[proddash] port ${PORT} is busy — waiting for it (a previous ProdDash may still be shutting down)`);
|
||||||
|
setTimeout(() => server.listen(PORT, '0.0.0.0'), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[proddash] cannot start:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0');
|
||||||
|
|
||||||
|
// Update check: shortly after boot, then every few hours (the admin page
|
||||||
|
// shows the result; nothing is installed without someone clicking).
|
||||||
|
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||||
|
setTimeout(() => refreshRemote(false).catch(() => {}), 20000).unref();
|
||||||
|
setInterval(() => refreshRemote(true).catch(() => {}), REMOTE_CHECK_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
for (const id of [...mounted.keys()]) unmountModule(id);
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Mock Planning Center Services API for developing the pco-plan module
|
||||||
|
* without a Planning Center account.
|
||||||
|
*
|
||||||
|
* node tools/pco-mock.js [port] (default 24700)
|
||||||
|
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js
|
||||||
|
*
|
||||||
|
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
|
||||||
|
* GET /services/v2/folders nested folders (parent rel)
|
||||||
|
* GET /services/v2/folders/{id}
|
||||||
|
* GET /services/v2/service_types every service type, with its
|
||||||
|
* parent folder relationship
|
||||||
|
* GET /services/v2/service_types/{id}
|
||||||
|
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
||||||
|
* per_page — today's plan,
|
||||||
|
* next week's and last week's
|
||||||
|
* GET …/plans/{id}
|
||||||
|
* GET …/plans/{id}/plan_times rehearsal + two services; the
|
||||||
|
* first service starts 90 s
|
||||||
|
* after the mock boots so the
|
||||||
|
* "Starts in" state is visible
|
||||||
|
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||||
|
* ItemNote compound documents
|
||||||
|
*
|
||||||
|
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
||||||
|
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||||
|
*
|
||||||
|
* Any request without an Authorization header (Basic or Bearer) gets a
|
||||||
|
* Planning-Center-style 401, so a missing/blank credential shows up the way
|
||||||
|
* it would in production. Every request is logged.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
|
||||||
|
const bootedAt = Date.now();
|
||||||
|
|
||||||
|
/* ── data ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const FOLDERS = [
|
||||||
|
{ id: '100', name: 'Campuses', parentId: '' },
|
||||||
|
{ id: '110', name: 'North Campus', parentId: '100' },
|
||||||
|
{ id: '120', name: 'Downtown', parentId: '100' },
|
||||||
|
{ id: '200', name: 'Archive', parentId: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVICE_TYPES = [
|
||||||
|
{ id: '1001', name: 'Sunday Service', frequency: 'Every week', parentId: '110' },
|
||||||
|
{ id: '1002', name: 'Wednesday Night', frequency: 'Every week', parentId: '110' },
|
||||||
|
{ id: '1003', name: 'Downtown Sunday', frequency: 'Every week', parentId: '120' },
|
||||||
|
{ id: '1004', name: 'Christmas Eve 2024', frequency: 'Every 12 months', parentId: '200' },
|
||||||
|
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function iso(ms) {
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function longDate(ms) {
|
||||||
|
return new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(ms) {
|
||||||
|
return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const firstServiceStart = bootedAt + 90 * 1000;
|
||||||
|
|
||||||
|
/** Plans for service type 1001: last week, today, next week. */
|
||||||
|
function plansFor(stId) {
|
||||||
|
if (stId !== '1001' && stId !== '1003') return [];
|
||||||
|
const mk = (id, serviceStart, title, series) => ({
|
||||||
|
id,
|
||||||
|
serviceTypeId: stId,
|
||||||
|
title,
|
||||||
|
series,
|
||||||
|
sortDate: serviceStart,
|
||||||
|
times: [
|
||||||
|
{ id: `${id}-r`, name: 'Rehearsal', type: 'rehearsal', start: serviceStart - 60 * 60000, end: serviceStart - 15 * 60000 },
|
||||||
|
{ id: `${id}-s1`, name: 'First Service', type: 'service', start: serviceStart, end: serviceStart + 75 * 60000 },
|
||||||
|
{ id: `${id}-s2`, name: 'Second Service', type: 'service', start: serviceStart + 120 * 60000, end: serviceStart + 195 * 60000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
|
||||||
|
mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
|
||||||
|
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function planRecord(p) {
|
||||||
|
const items = ITEMS.filter((it) => it.type !== 'header');
|
||||||
|
return {
|
||||||
|
type: 'Plan',
|
||||||
|
id: p.id,
|
||||||
|
attributes: {
|
||||||
|
title: p.title,
|
||||||
|
series_title: p.series,
|
||||||
|
dates: longDate(p.sortDate),
|
||||||
|
short_dates: shortDate(p.sortDate),
|
||||||
|
sort_date: iso(p.sortDate),
|
||||||
|
total_length: items.reduce((s, it) => s + it.length, 0),
|
||||||
|
items_count: ITEMS.length,
|
||||||
|
plan_notes_count: 0,
|
||||||
|
planning_center_url: `https://services.planningcenteronline.com/plans/${p.id}`,
|
||||||
|
},
|
||||||
|
relationships: { service_type: { data: { type: 'ServiceType', id: p.serviceTypeId } } },
|
||||||
|
links: { self: `/services/v2/service_types/${p.serviceTypeId}/plans/${p.id}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living
|
||||||
|
// Hope, Bumper Video, Closing Song, Benediction.
|
||||||
|
const ITEMS = [
|
||||||
|
{ id: 'i1', type: 'header', title: 'Pre-Service', pos: 'pre', length: 0 },
|
||||||
|
{ id: 'i2', type: 'media', title: 'Countdown', pos: 'pre', length: 300, desc: 'Start 5 min before', notes: [['Video', 'Loop the countdown from 8:55']] },
|
||||||
|
{ id: 'i3', type: 'header', title: 'Worship', pos: 'during', length: 0 },
|
||||||
|
{ id: 'i4', type: 'song', title: 'This Is The Day', pos: 'during', length: 270, key: 'G', song: { id: 's1', title: 'This Is The Day', author: 'Fred Hammond' }, arr: 'Default Arrangement', notes: [['Audio', 'Lead vocal: Sarah'], ['Lighting', 'Bright — house at 40%']] },
|
||||||
|
{ id: 'i5', type: 'song', title: 'Goodness of God', pos: 'during', length: 320, key: 'A', song: { id: 's2', title: 'Goodness of God', author: 'Bethel Music', ccli: '7117726' }, arr: 'Acoustic', notes: [['ProPresenter', 'Use the [ Full ] arrangement']] },
|
||||||
|
{ id: 'i6', type: 'item', title: 'Welcome', pos: 'during', length: 180, desc: 'Pastor Mike — mention Fall Kickoff', notes: [['Audio', 'Handheld 2 hot']] },
|
||||||
|
{ id: 'i7', type: 'media', title: 'Bumper Video', pos: 'during', length: 60, notes: [['Video', 'Program to Playback A']] },
|
||||||
|
{ id: 'i8', type: 'header', title: 'Message', pos: 'during', length: 0 },
|
||||||
|
{ id: 'i9', type: 'item', title: 'Sermon: Living Hope', pos: 'during', length: 2100, desc: '1 Peter 1:3-9', notes: [['ProPresenter', 'Sermon slides from PCO'], ['Lighting', 'Stage wash, house at 20%']] },
|
||||||
|
{ id: 'i10', type: 'song', title: 'Closing Song', pos: 'during', length: 240, key: 'D', song: { id: 's3', title: 'Build My Life', author: 'Housefires' }, arr: 'Default Arrangement' },
|
||||||
|
{ id: 'i11', type: 'item', title: 'Benediction', pos: 'during', length: 90 },
|
||||||
|
{ id: 'i12', type: 'header', title: 'Post-Service', pos: 'post', length: 0 },
|
||||||
|
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function itemsDocument(planId, include) {
|
||||||
|
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||||
|
const data = [];
|
||||||
|
const included = [];
|
||||||
|
ITEMS.forEach((it, index) => {
|
||||||
|
const relationships = {};
|
||||||
|
if (it.song) {
|
||||||
|
relationships.song = { data: { type: 'Song', id: it.song.id } };
|
||||||
|
if (wants.has('song')) {
|
||||||
|
included.push({ type: 'Song', id: it.song.id, attributes: { title: it.song.title, author: it.song.author, ccli_number: it.song.ccli ? Number(it.song.ccli) : null } });
|
||||||
|
}
|
||||||
|
if (it.arr) {
|
||||||
|
const arrId = `${it.song.id}-arr`;
|
||||||
|
relationships.arrangement = { data: { type: 'Arrangement', id: arrId } };
|
||||||
|
if (wants.has('arrangement')) included.push({ type: 'Arrangement', id: arrId, attributes: { name: it.arr } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const noteRefs = (it.notes || []).map((n, i) => ({ type: 'ItemNote', id: `${it.id}-n${i}` }));
|
||||||
|
relationships.item_notes = { data: noteRefs };
|
||||||
|
if (wants.has('item_notes')) {
|
||||||
|
(it.notes || []).forEach(([category, content], i) => {
|
||||||
|
included.push({ type: 'ItemNote', id: `${it.id}-n${i}`, attributes: { category_name: category, content } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data.push({
|
||||||
|
type: 'Item',
|
||||||
|
id: `${planId}-${it.id}`,
|
||||||
|
attributes: {
|
||||||
|
title: it.title,
|
||||||
|
sequence: index + 1,
|
||||||
|
item_type: it.type,
|
||||||
|
length: it.length,
|
||||||
|
service_position: it.pos,
|
||||||
|
description: it.desc || '',
|
||||||
|
html_details: '',
|
||||||
|
key_name: it.key || null,
|
||||||
|
},
|
||||||
|
relationships,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { data, included, meta: { total_count: data.length, count: data.length } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function folderRecord(f) {
|
||||||
|
return {
|
||||||
|
type: 'Folder',
|
||||||
|
id: f.id,
|
||||||
|
attributes: { name: f.name, container: f.parentId ? 'Folder' : 'Organization' },
|
||||||
|
relationships: { parent: { data: f.parentId ? { type: 'Folder', id: f.parentId } : null } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceTypeRecord(st) {
|
||||||
|
return {
|
||||||
|
type: 'ServiceType',
|
||||||
|
id: st.id,
|
||||||
|
attributes: { name: st.name, frequency: st.frequency, sequence: Number(st.id) },
|
||||||
|
relationships: { parent: { data: st.parentId ? { type: 'Folder', id: st.parentId } : null } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTimeRecord(t) {
|
||||||
|
return {
|
||||||
|
type: 'PlanTime',
|
||||||
|
id: t.id,
|
||||||
|
attributes: { name: t.name, time_type: t.type, starts_at: iso(t.start), ends_at: iso(t.end), live_starts_at: null, live_ends_at: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/vnd.api+json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collection(res, records, url) {
|
||||||
|
send(res, 200, {
|
||||||
|
links: { self: url.pathname + url.search },
|
||||||
|
data: records,
|
||||||
|
included: [],
|
||||||
|
meta: { total_count: records.length, count: records.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(res) {
|
||||||
|
send(res, 404, { errors: [{ code: 'not_found', title: 'Not Found', status: '404', detail: 'The resource you requested could not be found' }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
||||||
|
|
||||||
|
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
||||||
|
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = url.pathname.replace(/^\/services\/v2\/?/, '').split('/').filter(Boolean);
|
||||||
|
if (!url.pathname.startsWith('/services/v2')) return notFound(res);
|
||||||
|
|
||||||
|
// /folders[/{id}]
|
||||||
|
if (parts[0] === 'folders') {
|
||||||
|
if (parts.length === 1) return collection(res, FOLDERS.map(folderRecord), url);
|
||||||
|
const f = FOLDERS.find((x) => x.id === parts[1]);
|
||||||
|
if (!f) return notFound(res);
|
||||||
|
if (parts.length === 2) return send(res, 200, { data: folderRecord(f) });
|
||||||
|
if (parts[2] === 'folders') return collection(res, FOLDERS.filter((x) => x.parentId === f.id).map(folderRecord), url);
|
||||||
|
if (parts[2] === 'service_types') return collection(res, SERVICE_TYPES.filter((x) => x.parentId === f.id).map(serviceTypeRecord), url);
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]]
|
||||||
|
if (parts[0] === 'service_types') {
|
||||||
|
if (parts.length === 1) return collection(res, SERVICE_TYPES.map(serviceTypeRecord), url);
|
||||||
|
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||||
|
if (!st) return notFound(res);
|
||||||
|
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||||
|
if (parts[2] !== 'plans') return notFound(res);
|
||||||
|
const plans = plansFor(st.id);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const filter = url.searchParams.get('filter') || '';
|
||||||
|
const now = Date.now();
|
||||||
|
let list = plans.slice();
|
||||||
|
if (filter === 'future') list = list.filter((p) => p.sortDate >= new Date(now).setHours(0, 0, 0, 0));
|
||||||
|
else if (filter === 'past') list = list.filter((p) => p.sortDate < now);
|
||||||
|
const order = url.searchParams.get('order') || 'sort_date';
|
||||||
|
list.sort((a, b) => (order.startsWith('-') ? b.sortDate - a.sortDate : a.sortDate - b.sortDate));
|
||||||
|
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('per_page')) || 25));
|
||||||
|
return collection(res, list.slice(0, perPage).map(planRecord), url);
|
||||||
|
}
|
||||||
|
const plan = plans.find((p) => p.id === parts[3]);
|
||||||
|
if (!plan) return notFound(res);
|
||||||
|
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
||||||
|
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
||||||
|
if (parts[4] === 'items') {
|
||||||
|
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||||
|
doc.links = { self: url.pathname + url.search };
|
||||||
|
return send(res, 200, doc);
|
||||||
|
}
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[pco-mock] Planning Center mock on http://127.0.0.1:${PORT}/services/v2`);
|
||||||
|
console.log(`[pco-mock] today's plan "${plansFor('1001')[1].title}" — first service starts at ${new Date(firstServiceStart).toLocaleTimeString()}`);
|
||||||
|
console.log('[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js', PORT);
|
||||||
|
});
|
||||||
+139
-1
@@ -4,6 +4,7 @@
|
|||||||
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
|
||||||
*
|
*
|
||||||
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
|
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
|
||||||
|
* [--stage-pwd=<pwd>] [--no-ltc-field]
|
||||||
*
|
*
|
||||||
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
* Serves the endpoint shapes verified against openapi.propresenter.com:
|
||||||
* GET /version (reachability probe)
|
* GET /version (reachability probe)
|
||||||
@@ -23,6 +24,16 @@
|
|||||||
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
|
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
|
||||||
* ProPresenter (a solo tile for it must say so, not error).
|
* ProPresenter (a solo tile for it must say so, not error).
|
||||||
*
|
*
|
||||||
|
* Also serves the classic stage-display websocket at /stagedisplay (the
|
||||||
|
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
|
||||||
|
* per-second "sys" clock, and "tmr" text updates — including a stage layout
|
||||||
|
* with a field labeled "LTC" whose text advances at 30 fps and freezes
|
||||||
|
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
|
||||||
|
* with --no-ltc to make the stage feed the only timecode source (the module
|
||||||
|
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
|
||||||
|
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
|
||||||
|
* --no-ltc-field serves the stage layout without the LTC field.
|
||||||
|
*
|
||||||
* Every incoming request is logged so we can confirm what the module fetches.
|
* Every incoming request is logged so we can confirm what the module fetches.
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -33,6 +44,11 @@ const args = process.argv.slice(2);
|
|||||||
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
|
||||||
const NO_LTC = args.includes('--no-ltc');
|
const NO_LTC = args.includes('--no-ltc');
|
||||||
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
|
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
|
||||||
|
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
|
||||||
|
const NO_LTC_FIELD = args.includes('--no-ltc-field');
|
||||||
|
// Like real ProPresenter 21: the layout list omits the timecode field, but
|
||||||
|
// its updates still stream — exercises the module's heuristic binding.
|
||||||
|
const LTC_UNLISTED = args.includes('--ltc-unlisted');
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
|
|||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
|
const cryptoMod = require('crypto');
|
||||||
|
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
|
||||||
|
const STAGE_LAYOUT = {
|
||||||
|
acn: 'sl',
|
||||||
|
uid: 'MOCK-LAYOUT-1',
|
||||||
|
nme: 'Timers',
|
||||||
|
fme: [
|
||||||
|
{ nme: 'Current Slide', typ: 1 },
|
||||||
|
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
|
||||||
|
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
|
||||||
|
function wsEncode(str) {
|
||||||
|
const p = Buffer.from(str);
|
||||||
|
let h;
|
||||||
|
if (p.length < 126) {
|
||||||
|
h = Buffer.from([0x81, p.length]);
|
||||||
|
} else {
|
||||||
|
h = Buffer.alloc(4);
|
||||||
|
h[0] = 0x81;
|
||||||
|
h[1] = 126;
|
||||||
|
h.writeUInt16BE(p.length, 2);
|
||||||
|
}
|
||||||
|
return Buffer.concat([h, p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket) => {
|
||||||
|
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
if (!key) return void socket.destroy();
|
||||||
|
console.log('[pp-timers-mock] stage display client connected');
|
||||||
|
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||||
|
|
||||||
|
const send = (obj) => {
|
||||||
|
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
|
||||||
|
};
|
||||||
|
let authed = false;
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
let lastLtcText = '';
|
||||||
|
|
||||||
|
const sysTimer = setInterval(() => {
|
||||||
|
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
|
||||||
|
}, 1000);
|
||||||
|
const tmrTimer = setInterval(() => {
|
||||||
|
if (!authed) return;
|
||||||
|
// noise a client must filter out: an ordinary timer's text
|
||||||
|
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
|
||||||
|
}, 1000);
|
||||||
|
const ltcTimer = setInterval(() => {
|
||||||
|
if (!authed || NO_LTC_FIELD) return;
|
||||||
|
const tc = timecodeStatus();
|
||||||
|
// During no-signal the on-stage field is frozen: no updates at all —
|
||||||
|
// exactly what the module's staleness heuristic must catch.
|
||||||
|
if (!tc.receiving || tc.time === lastLtcText) return;
|
||||||
|
lastLtcText = tc.time;
|
||||||
|
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
while (buf.length >= 2) {
|
||||||
|
const opcode = buf[0] & 0x0f;
|
||||||
|
const masked = (buf[1] & 0x80) !== 0;
|
||||||
|
let len = buf[1] & 0x7f;
|
||||||
|
let off = 2;
|
||||||
|
if (len === 126) {
|
||||||
|
if (buf.length < 4) return;
|
||||||
|
len = buf.readUInt16BE(2);
|
||||||
|
off = 4;
|
||||||
|
} else if (len === 127) {
|
||||||
|
if (buf.length < 10) return;
|
||||||
|
len = Number(buf.readBigUInt64BE(2));
|
||||||
|
off = 10;
|
||||||
|
}
|
||||||
|
const maskLen = masked ? 4 : 0;
|
||||||
|
if (buf.length < off + maskLen + len) return;
|
||||||
|
let payload = buf.slice(off + maskLen, off + maskLen + len);
|
||||||
|
if (masked) {
|
||||||
|
const mask = buf.slice(off, off + 4);
|
||||||
|
payload = Buffer.from(payload);
|
||||||
|
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
||||||
|
}
|
||||||
|
buf = buf.slice(off + maskLen + len);
|
||||||
|
|
||||||
|
if (opcode === 0x8) return void socket.destroy();
|
||||||
|
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
|
||||||
|
if (opcode !== 0x1) continue;
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(payload.toString()); } catch { continue; }
|
||||||
|
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
|
||||||
|
if (msg.acn === 'ath') {
|
||||||
|
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
|
||||||
|
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
|
||||||
|
} else {
|
||||||
|
authed = true;
|
||||||
|
send({ acn: 'ath', ath: true, err: '' });
|
||||||
|
}
|
||||||
|
} else if (msg.acn === 'psl') {
|
||||||
|
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
|
||||||
|
} else if (msg.acn === 'asl') {
|
||||||
|
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(sysTimer);
|
||||||
|
clearInterval(tmrTimer);
|
||||||
|
clearInterval(ltcTimer);
|
||||||
|
};
|
||||||
|
socket.on('close', cleanup);
|
||||||
|
socket.on('error', () => { cleanup(); socket.destroy(); });
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
|
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user