Compare commits
170
Commits
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "start",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"start"
|
||||
],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-3
@@ -8,9 +8,8 @@ dist/
|
||||
config/modules.json
|
||||
config/layouts/
|
||||
|
||||
# compiled per-machine, source sits next to it
|
||||
modules/propresenter-timers/ltc-capture
|
||||
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
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
|
||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||
the server and its own Node runtime, supervises them, and owns the macOS
|
||||
permissions modules declare; see its README). Runtime state (module
|
||||
@@ -28,22 +29,36 @@ title-bar controls). The rules that matter most:
|
||||
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"`
|
||||
started in `init()` and stopped in `stop()`. **A native helper ships
|
||||
prebuilt inside the module folder for Apple silicon macOS and 64-bit
|
||||
Windows** (`bin/<name>-macos-arm64`, `bin/<name>-win-x64.exe`, source and
|
||||
build script under `native/`); nothing is ever compiled on the machine
|
||||
running ProdDash, and a missing binary means the feature reports itself
|
||||
off, not a broken module. 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
|
||||
proxy everything (no CORS, secrets stay server-side).
|
||||
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
|
||||
over three words in a tile's gear; server-side settings in `configSchema`
|
||||
only; a tile setting changes that tile alone; hide-toggles ungrouped when
|
||||
there are four or fewer, in a folded **Show** group when there are five or
|
||||
more; title-bar controls are icons with tooltips, never labels, and never
|
||||
repeat something the gear already offers.
|
||||
- Style with the shell's CSS variables only, scoped under
|
||||
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
|
||||
- Verify against the guide's testing section and resilience checklist. Mocks
|
||||
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
||||
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
||||
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
||||
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
||||
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
|
||||
(set per site; each campus runs its own). Good reference for: SSE handling,
|
||||
reconnect logic, channel colors/filtering, PWA install.
|
||||
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
||||
mic/pack labels) plus ProPresenter Now/Next:
|
||||
|
||||
@@ -15,9 +15,10 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
|
||||
2. Discovers modules in `modules/` and serves each one's client at
|
||||
`/modules/<id>/`.
|
||||
3. Mounts each enabled module's server routes under `/api/modules/<id>/` —
|
||||
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
||||
live updates over SSE, so browsers never talk to production gear
|
||||
directly (no CORS, and API keys never leave the server).
|
||||
that's where module servers follow ProdCom, poll ProPresenter, and stream
|
||||
live updates over SSE — one upstream connection per module, however many
|
||||
dashboards are open — so browsers never talk to production gear directly
|
||||
(no CORS, and API keys never leave the server).
|
||||
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.
|
||||
|
||||
@@ -29,11 +30,23 @@ and a clear degraded state, then recover on their own.
|
||||
|
||||
## Run it
|
||||
|
||||
Ready-made builds are on the
|
||||
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||
the files themselves live in
|
||||
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||
admin page, so the download only has to be recent, not current.
|
||||
|
||||
### macOS — the app
|
||||
|
||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
||||
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||
**ProdDash** to Applications. That's the whole
|
||||
installation: the app carries the dashboard and its own Node runtime, so
|
||||
nothing else has to be installed and nothing can be missing. It lives in the
|
||||
nothing else has to be installed. It carries no modules — open **Admin →
|
||||
Available modules**, tick the ones this machine needs and press **Install
|
||||
selected**; each updates on its own from then on. It lives in the
|
||||
menu bar, starts and stops the server, can open at login, and holds the
|
||||
permissions the modules need — the microphone the LTC listener reads, and
|
||||
local network access for ProdCom and ProPresenter. macOS grants those to the
|
||||
@@ -42,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
||||
zeroes.
|
||||
|
||||
Build the disk image from a checkout with
|
||||
[launcher/macos](launcher/macos)/`build.sh`:
|
||||
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||
for Intel):
|
||||
|
||||
```bash
|
||||
cd launcher/macos && ./build.sh --dmg
|
||||
```
|
||||
|
||||
### Windows — the bundle
|
||||
|
||||
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||
dashboard running: it stays open, and it starts the server again when the
|
||||
admin page applies an update. Allow `node.exe` on private networks when the
|
||||
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||
[launcher/windows](launcher/windows)/`build.sh`.
|
||||
|
||||
### Anywhere else, or from a checkout
|
||||
|
||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||
@@ -98,10 +125,34 @@ with this machine's shell settings — the same keys as the checked-in
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
|
||||
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
|
||||
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
|
||||
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||
overrides the PIN and always gets you in.
|
||||
|
||||
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
|
||||
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
|
||||
4–8 digit send PIN, say), which guards that module's actions only.
|
||||
|
||||
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
|
||||
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
||||
base URL and `repo` at the owner/repo there:
|
||||
|
||||
```json
|
||||
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
|
||||
```
|
||||
|
||||
Gitea spells an archive URL differently from GitHub, which is the only thing
|
||||
that changes; the tarball itself is identical, commit hash included, so update
|
||||
detection and module installs behave exactly as before. Make the repository
|
||||
readable without signing in, or ProdDash cannot fetch it.
|
||||
|
||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||
`PRODDASH_FORGE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||
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.
|
||||
@@ -111,25 +162,58 @@ so and falls back to the app's `config/` folder.
|
||||
directory, so nothing has to be re-entered. The old files are left in place and
|
||||
ignored from then on.
|
||||
|
||||
### Themes
|
||||
|
||||
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||
and open dashboards change without a reload. The section starts folded —
|
||||
a building picks its palette once — and holds twelve presets:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Booth** | the default dark, mint accent |
|
||||
| **Harbor** | deep navy, cyan |
|
||||
| **Graphite** | neutral charcoal, electric blue |
|
||||
| **Ember** | warm broadcast black, orange |
|
||||
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
|
||||
| **Midnight** | indigo night, violet |
|
||||
| **Moss** | forest floor, lime |
|
||||
| **Plum** | aubergine, purple |
|
||||
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
|
||||
| **Blackout** | true black, cyan, for a dark room where any glow carries |
|
||||
| **Paper** | the warm light one, indigo accent |
|
||||
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
|
||||
|
||||
Each keeps the status colours apart — the accent is never near the danger red
|
||||
or the warning amber — and muted text readable in a dark booth. The choice is
|
||||
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||
it holds across restarts and updates; a browser remembers the last theme it
|
||||
saw and applies it before the server answers, so nothing flashes. Modules
|
||||
style with the shell's colour variables, which is why they follow; a colour
|
||||
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
|
||||
|
||||
## 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
|
||||
Everything below happens in the **admin page**; ProdDash talks to its git
|
||||
repository — `michael/ProdDash` on the church's own Gitea at
|
||||
`chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
|
||||
`repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
|
||||
`PRODDASH_REPO` variables (`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.
|
||||
restart is needed. Tick several and press **Install selected** to fetch
|
||||
them in one go. A packaged ProdDash (the app, the Windows bundle) ships
|
||||
with no modules at all: this list is how every one of them arrives, and a
|
||||
ProdDash update never brings one along. A git checkout also runs the
|
||||
modules in its own tree ("bundled"); 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.
|
||||
checkout's bundled module (its folder belongs to the repo) 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
|
||||
@@ -144,7 +228,18 @@ below. Nothing is installed without someone clicking.
|
||||
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.
|
||||
never touched — nothing inside the data directory is written, even on an
|
||||
install where that directory *is* the app's own `config/`. **What changed**
|
||||
opens the commit comparison on the forge.
|
||||
- **If an update goes wrong**: the download has to parse before anything is
|
||||
swapped in, so a truncated or mangled `server.js` is refused with the
|
||||
reason and the running copy is left exactly as it was. Past that, the copy
|
||||
being replaced is set aside as `app.previous` next to the app folder, and
|
||||
dropped once the new version has stayed up for ninety seconds — so that
|
||||
folder existing means "the last update has not proven itself yet". The
|
||||
macOS app watches for a version that will not stay up (four stops in two
|
||||
minutes) and puts the old one back by itself, once, with a notification.
|
||||
Running some other way, the way back is a rename: `mv app.previous app`.
|
||||
- **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
|
||||
@@ -156,13 +251,17 @@ below. Nothing is installed without someone clicking.
|
||||
|
||||
- **+** — add any enabled module; multiple tiles of the same module are
|
||||
fine (e.g. two transcripts filtered to different channels).
|
||||
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||
appears on its **bottom edge** — drag it to change just the height. If
|
||||
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).
|
||||
- Drag a tile by its **title bar** to move it; **✕** removes it.
|
||||
- **Resize from any edge or bottom corner**, the way a window resizes:
|
||||
nothing is drawn on a tile to mark the spots — the cursor over an edge is
|
||||
what says it can be dragged. An edge moves only itself, so the tile grows
|
||||
or shrinks into the space beside it while the opposite edge stays put,
|
||||
stopping at a neighbour or at the module's own minimum size. Two
|
||||
exceptions, both of which move a *shared* edge instead: with another tile
|
||||
directly below, the bottom edge trades rows between the two so the column
|
||||
keeps its total height; and where two tiles meet side by side, the seam
|
||||
between them trades width (macOS split-view style), showing itself only
|
||||
while you drag it.
|
||||
- **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
|
||||
@@ -172,9 +271,21 @@ below. Nothing is installed without someone clicking.
|
||||
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**;
|
||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||
- A tile's actions (menus, text size, clear…) live in its **title bar**.
|
||||
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
|
||||
a windowed screen that should read like a wall display. A folded bar is
|
||||
not a gone bar: point at the tile's top edge and it slides back in over
|
||||
the content, where its buttons click and its width drags the tile exactly
|
||||
as before — so a dashboard can be arranged with every bar out of the way.
|
||||
The switch is off on a fresh dashboard and saves with the layout —
|
||||
including a named layout on the server, so a booth loads its screen with
|
||||
the bars already tucked away. Fullscreen folds every bar regardless, and
|
||||
the same hover brings one back there too.
|
||||
- The **pin** beside a tile's title keeps *that* tile's bar showing while
|
||||
auto-hide is on — for the one or two tiles whose controls are wanted at
|
||||
a glance. It turns the accent colour when it is on and saves with the
|
||||
layout. Fullscreen still folds a pinned bar with the rest.
|
||||
- The menu bar's own 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
|
||||
@@ -183,7 +294,21 @@ below. Nothing is installed without someone clicking.
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard.
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
|
||||
4K wall), coarser suits a small screen read close up; row height follows,
|
||||
so tiles keep their shape and simply change size. Going coarser, a tile
|
||||
that no longer fits where it was moves to the first free spot rather than
|
||||
onto another tile. It saves with the layout, so a named layout comes back
|
||||
at the density it was arranged on.
|
||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||
automatically.
|
||||
@@ -198,11 +323,19 @@ below. Nothing is installed without someone clicking.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
|
||||
the live item alone with the gradient filling the tile edge to edge — a
|
||||
wall panel that says one thing.
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
Tile settings: a **Show** group of switches for the playlist name, live
|
||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||
its item tags, plus tidy titles.
|
||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||
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"
|
||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||
stay hidden) 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" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||
@@ -220,18 +353,133 @@ below. Nothing is installed without someone clicking.
|
||||
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
|
||||
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
||||
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
||||
Key** (the key of the item on screen).
|
||||
|
||||
**Plan** shows 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
|
||||
ahead/behind the service is. Items are matched by name — a document named
|
||||
like the Planning Center item or its song, tags and all. The message is
|
||||
the exception: a sermon's document is named after the sermon, so a live
|
||||
document that reads like the plan itself — its date code, series title
|
||||
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||
message and lands on the plan's message item (an item titled Message,
|
||||
Sermon or Teaching, else the first item under such a header). 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.
|
||||
|
||||
With an **AbleSet** module installed and configured, the Plan tile also
|
||||
follows the band: while AbleSet plays a song that is the item on screen,
|
||||
that item's background fills as the song goes, and its sections slide past
|
||||
in a strip at the bottom right of the item, the one playing filling in
|
||||
turn. Both are switches in the tile's gear (**Song progress**, **Song
|
||||
sections**), and both are simply absent when there is no AbleSet module, it
|
||||
is switched off, or it has no host — nothing to turn off and nothing to
|
||||
explain.
|
||||
|
||||
**Song Key** is one value, as large as the tile allows: the key of the item
|
||||
ProPresenter has live, with the song's name and the next key under it — both
|
||||
switches, as is the colour. When the feed that picks the item is gone the key
|
||||
stays up, faded, and the tile's dot says so rather than pretending to be
|
||||
current.
|
||||
|
||||
Keys are written as notes wherever the module shows one: Planning Center's
|
||||
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
|
||||
Song Key tile alike.
|
||||
|
||||
**Teams** shows the same plan's team and position assignments — each team
|
||||
as a band of its people, with their photo (fetched through ProdDash, never
|
||||
by the browser; initials when Planning Center has none) and a dot for
|
||||
confirmed, unconfirmed or declined. Its gear menu picks which teams and
|
||||
which positions appear, groups people under their position or lists them
|
||||
plainly, and sets the colours: one for the team headers, or **Tint teams**
|
||||
to give each team its own, optionally carried into the rows. Teams nobody
|
||||
has signed up for are hidden until you ask for them — that list comes from
|
||||
the service type, since Planning Center only returns a team once somebody
|
||||
is on it.
|
||||
- **Slack** — live transcripts of this location's Slack channels, plus
|
||||
PIN-protected sending as the location. The + picker lists one transcript
|
||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
||||
of each per dashboard): **Send message**, a free-text box with the admin's
|
||||
quick emojis one press away, and **Quick replies**, a grid of preset
|
||||
buttons. Both post to the channel of whichever transcript was clicked last
|
||||
— its bottom edge lights up in the accent color — and to the first
|
||||
configured channel until one is clicked. Messages arrive with avatars,
|
||||
names and times, Slack's inline formatting and the workspace's custom
|
||||
emoji; a right-click on one opens the reaction menu, and text size and
|
||||
jump-to-latest sit in the title bar. Nothing leaves the machine until the
|
||||
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
|
||||
until the auto-lock idles out, a reload asks again, and the lock button on
|
||||
a send tile's title bar ends it early.
|
||||
Admin config is in four groups: **Slack account** (the location account's
|
||||
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
|
||||
or a bot token plus "Send as"), **Channels** (the joined channels to
|
||||
follow, one transcript tile each), **Transcript** (refresh interval,
|
||||
messages kept, thread replies) and **Sending** (the switch on the group's
|
||||
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
|
||||
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
||||
to eight quick emojis).
|
||||
Tile settings: avatars, timestamps, names and channel notices.
|
||||
- **Resi Cues** — how long until the next Resi cue point, for a campus
|
||||
running video from another site. The tile is one number, as large as it
|
||||
will go: the countdown to the next cue, its name above it, and the playhead
|
||||
and the cue just passed underneath. It also offers that countdown and the
|
||||
playhead to the **Timers** module, so they can sit among the ProPresenter
|
||||
timers instead of in a tile of their own.
|
||||
Both the cue list and the decoder's position come from Resi's cloud rather
|
||||
than the decoder — the decoder reports upstream and central.resi.io hands it
|
||||
back — so nothing is installed at the decoder end. Resi republishes a
|
||||
position only every few seconds and each reading is already stale, so the
|
||||
playhead is estimated between readings rather than repeated; a countdown
|
||||
built straight from Resi's number sits seconds behind and moves in lurches.
|
||||
Admin config: Resi login and password, which decoder to follow (the list is
|
||||
read from the account), and a folded **Timing** group for the poll
|
||||
intervals and a position trim.
|
||||
Tile settings: cue name, previous cue, playhead, cue list.
|
||||
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
||||
slot with photo, name and mic/pack label, on one shared board that every
|
||||
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
teams to include, the stage slots (one mic/pack label per line, in stage
|
||||
order), default columns, and the photo folder. Tile settings: parallax,
|
||||
names, mic labels, and birthday confetti with its intensity and a test
|
||||
picker — columns are − / + in the title bar.
|
||||
- **Service Times** — every campus's Sunday service times, read from the
|
||||
church website and re-read on a timer, so the board follows the site when
|
||||
the times change through the year. Any service can be marked for
|
||||
production — issue, replay, or do not use — with a note, and a campus
|
||||
taking resi records which of the origin's services it is playing. Marking
|
||||
a broadcast service "do not use" flags every campus riding on it, which is
|
||||
the question the board exists to answer. Marks are shared by every tile
|
||||
and every browser, survive a restart, and expire on their own so last
|
||||
week's never greet a new Sunday. The + picker offers the whole board or a
|
||||
single campus.
|
||||
Admin config is in four groups: **Website** (address, which campuses to
|
||||
follow, refresh interval), **Broadcast** (the campuses the broadcast comes
|
||||
from, and each other campus's resi source), **Manual campuses** (a stopgap
|
||||
for a campus the site has not published yet — the website takes over as
|
||||
soon as it does) and **Marks** (how long a mark lives).
|
||||
Tile settings: campuses, card or list layout, and a **Show** group for the
|
||||
day, role, resi source, campuses fed, notes and the updated line.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
@@ -239,10 +487,15 @@ To develop modules against mocks instead of real gear:
|
||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
|
||||
timers + timecode on `127.0.0.1:1600`), `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).
|
||||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
|
||||
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
|
||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ modules/<id>/
|
||||
"minSize": { "w": 2, "h": 2 },
|
||||
"defaultSize": { "w": 4, "h": 3 },
|
||||
"configSchema": {
|
||||
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" },
|
||||
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
|
||||
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
@@ -57,9 +57,37 @@
|
||||
<option value="">Choose service type</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-test-birthdays">Test Birthdays</button>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
||||
</div>
|
||||
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
|
||||
<div class="pco-status pco-birthday-result" id="pco-birthday-result"></div>
|
||||
|
||||
<div class="bday-block">
|
||||
<div class="bday-head">
|
||||
<span class="bday-title">Birthday Mode 🎂</span>
|
||||
<label class="bday-switch">
|
||||
<input type="checkbox" id="bday-enabled" />
|
||||
<span class="bday-switch-label">On</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bday-controls" id="bday-controls">
|
||||
<div class="bday-line">
|
||||
<span class="bday-line-label">Intensity</span>
|
||||
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
|
||||
<button type="button" data-int="subtle">Subtle</button>
|
||||
<button type="button" data-int="full">Full</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bday-line">
|
||||
<span class="bday-line-label">Force on <em>(test)</em></span>
|
||||
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
|
||||
<option value="">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pco-position-builder">
|
||||
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
||||
<option value="">Choose position</option>
|
||||
|
||||
+167
-1
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
|
||||
return payload || { data: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
|
||||
* "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
|
||||
* birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
|
||||
*/
|
||||
async function pcoFetchStatus(target, credentials, searchParams) {
|
||||
const url = buildPcoUrl(target, searchParams);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: pcoAuthHeader(credentials),
|
||||
'User-Agent': pcoUserAgent(),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
return { status: response.status, ok: response.ok, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
|
||||
* share the same person id, so the Services person id we already store doubles as the People id.
|
||||
* Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
|
||||
*/
|
||||
async function pcoFetchBirthday(personId, credentials) {
|
||||
if (!personId) return { status: 0, birthday: '', fieldPresent: false };
|
||||
const { status, ok, payload } = await pcoFetchStatus(
|
||||
`/people/v2/people/${encodeURIComponent(personId)}`,
|
||||
credentials,
|
||||
{ 'fields[Person]': 'birthdate' },
|
||||
);
|
||||
if (!ok) return { status, birthday: '', fieldPresent: false };
|
||||
const attrs = payload?.data?.attributes || {};
|
||||
return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
|
||||
}
|
||||
|
||||
async function pcoFetchAll(target, credentials, searchParams) {
|
||||
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
||||
const data = [];
|
||||
@@ -275,6 +316,15 @@ function emptyState() {
|
||||
pcoDisplayPositions: [],
|
||||
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
||||
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
||||
birthdayMode: { enabled: true, intensity: 'subtle' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
|
||||
function normalizeBirthdayMode(entry) {
|
||||
return {
|
||||
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
|
||||
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
|
||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
||||
// "MM-DD" only — we deliberately never persist the birth year (month + day is all
|
||||
// Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
|
||||
birthday: normalizeBirthdayMmdd(person?.birthday),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
|
||||
function normalizeBirthdayMmdd(raw) {
|
||||
const s = String(raw || '').trim();
|
||||
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
|
||||
if (full) return `${full[1]}-${full[2]}`;
|
||||
const md = s.match(/^(\d{2})-(\d{2})$/);
|
||||
if (md) {
|
||||
const mm = Number(md[1]);
|
||||
const dd = Number(md[2]);
|
||||
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeTeamName(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
|
||||
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
// Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
|
||||
// person with no date on file just yields '' and never fails the sync). Fetched once per unique
|
||||
// person so a member in two positions isn't queried twice.
|
||||
const birthdayByPerson = new Map();
|
||||
for (const member of scheduledMembers) {
|
||||
if (!member.personId || birthdayByPerson.has(member.personId)) continue;
|
||||
try {
|
||||
const { birthday } = await pcoFetchBirthday(member.personId, credentials);
|
||||
birthdayByPerson.set(member.personId, birthday || '');
|
||||
} catch {
|
||||
birthdayByPerson.set(member.personId, '');
|
||||
}
|
||||
}
|
||||
|
||||
const oldPcoFiles = new Set(
|
||||
(Array.isArray(baseState.library) ? baseState.library : [])
|
||||
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
||||
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
|
||||
source: 'pco',
|
||||
pcoPersonId: member.personId,
|
||||
pcoPlanPersonId: member.planPersonId,
|
||||
birthday: birthdayByPerson.get(member.personId) || '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
|
||||
const atWriteTime = await loadStateRaw();
|
||||
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
||||
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
||||
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
|
||||
await saveStateRaw(nextState);
|
||||
|
||||
for (const fileName of oldPcoFiles) {
|
||||
@@ -1222,6 +1305,7 @@ function migrateState(data) {
|
||||
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
||||
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
||||
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
||||
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
|
||||
lineup: data.lineup.map((e) => {
|
||||
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
||||
return {
|
||||
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
|
||||
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
||||
// always carry the stored value forward rather than letting a save blank it out.
|
||||
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
||||
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
|
||||
const birthdayMode = payload?.birthdayMode
|
||||
? normalizeBirthdayMode(payload.birthdayMode)
|
||||
: normalizeBirthdayMode(existing.birthdayMode);
|
||||
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
|
||||
return syncUpcomingPcoPlan(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
|
||||
* People API and read birthdates, and how many synced band members have one on file. Read-only.
|
||||
*/
|
||||
ipcMain.handle('pco-test-birthdays', async () => {
|
||||
const credentials = await loadPcoCredentials();
|
||||
if (!credentials) return { connected: false };
|
||||
|
||||
// 1. Can the token read the People API at all?
|
||||
let probe;
|
||||
try {
|
||||
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
|
||||
} catch (err) {
|
||||
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!probe.ok) {
|
||||
const detail =
|
||||
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
|
||||
`${probe.status}`;
|
||||
return { connected: true, canRead: false, status: probe.status, detail };
|
||||
}
|
||||
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
|
||||
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
|
||||
|
||||
// 2. Coverage across the people we've actually synced (unique by person id).
|
||||
const state = await loadStateRaw();
|
||||
const seen = new Set();
|
||||
const targets = [];
|
||||
for (const p of Array.isArray(state.library) ? state.library : []) {
|
||||
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
|
||||
seen.add(p.pcoPersonId);
|
||||
targets.push(p);
|
||||
}
|
||||
|
||||
let withBirthday = 0;
|
||||
let notFound = 0;
|
||||
let errors = 0;
|
||||
const found = [];
|
||||
for (const p of targets) {
|
||||
let r;
|
||||
try {
|
||||
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
|
||||
} catch {
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
if (r.status === 404) { notFound += 1; continue; }
|
||||
if (r.status && r.status >= 400) { errors += 1; continue; }
|
||||
if (r.birthday) {
|
||||
withBirthday += 1;
|
||||
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
|
||||
}
|
||||
}
|
||||
|
||||
// Soonest upcoming birthday, for a concrete "it works" line.
|
||||
const today = new Date();
|
||||
const daysUntil = (mmdd) => {
|
||||
const [mm, dd] = mmdd.split('-').map(Number);
|
||||
let next = new Date(today.getFullYear(), mm - 1, dd);
|
||||
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
|
||||
return Math.round((next - midnight) / 86400000);
|
||||
};
|
||||
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
|
||||
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
canRead: true,
|
||||
fieldExposed,
|
||||
total: targets.length,
|
||||
withBirthday,
|
||||
notFound,
|
||||
errors,
|
||||
found: sortedFound.slice(0, 8),
|
||||
nextUp,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('pp-status', async () => {
|
||||
return {
|
||||
enabled: ppConfig.enabled,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "band-lineup-display",
|
||||
"productName": "Band Lineup",
|
||||
"version": "1.3.6",
|
||||
"buildNumber": "92",
|
||||
"version": "1.3.10",
|
||||
"buildNumber": "96",
|
||||
"description": "Stage lineup photos with mic/pack labels",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
|
||||
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
||||
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
||||
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
||||
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
|
||||
onMenuAddPhotos: (cb) => {
|
||||
ipcRenderer.on('menu-add-photos', () => cb());
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
|
||||
} else {
|
||||
window.__bandLineupRendererLoaded = true;
|
||||
/**
|
||||
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
|
||||
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
|
||||
* @typedef {{ personId: string, label: string }} StageSlot
|
||||
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
||||
*/
|
||||
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
|
||||
let pcoPositionOptions = [];
|
||||
let slotLabelMemory = [];
|
||||
let slotNameMemory = [];
|
||||
/** Birthday Mode display settings (persisted). */
|
||||
let birthdayMode = { enabled: true, intensity: 'subtle' };
|
||||
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
|
||||
let birthdayTestPersonId = '';
|
||||
/** @type {StageSlot[]} */
|
||||
let stageSlots = createEmptySlots();
|
||||
const imageUrlCache = new Map();
|
||||
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
|
||||
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
||||
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
||||
const btnPcoSync = document.getElementById('btn-pco-sync');
|
||||
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
|
||||
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
|
||||
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
|
||||
const bdayEnabled = document.getElementById('bday-enabled');
|
||||
const bdayIntensity = document.getElementById('bday-intensity');
|
||||
const bdayControls = document.getElementById('bday-controls');
|
||||
const bdayTestSelect = document.getElementById('bday-test');
|
||||
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
|
||||
const inputLayoutCols = document.getElementById('layout-cols');
|
||||
const inputLayoutRows = document.getElementById('layout-rows');
|
||||
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
|
||||
function setPcoControlsEnabled(enabled) {
|
||||
btnPcoConnect.disabled = !enabled;
|
||||
btnPcoSync.disabled = !enabled;
|
||||
btnPcoTestBirthdays.disabled = !enabled;
|
||||
btnPcoDisconnect.disabled = !enabled;
|
||||
btnPcoAddPosition.disabled = !enabled;
|
||||
pcoServiceTypeSelect.disabled = !enabled;
|
||||
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
|
||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
||||
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
|
||||
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
|
||||
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
|
||||
}))
|
||||
: [];
|
||||
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
|
||||
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
|
||||
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
||||
renderPcoDisplayPositions();
|
||||
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
|
||||
birthdayMode = {
|
||||
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
|
||||
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
|
||||
};
|
||||
syncBirthdayControls();
|
||||
}
|
||||
|
||||
function updateSlotSizeReadout() {
|
||||
@@ -528,6 +547,7 @@ function scheduleSave() {
|
||||
slotLabels: slotLabelMemory,
|
||||
slotNames: slotNameMemory,
|
||||
lineup: stageSlots,
|
||||
birthdayMode,
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- Birthday Mode ---------------- */
|
||||
|
||||
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
|
||||
const birthdayCanvases = new Set();
|
||||
let birthdayRaf = null;
|
||||
|
||||
/**
|
||||
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
|
||||
* anywhere in this window celebrates all week (so it's already up for the weekend services).
|
||||
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
|
||||
*/
|
||||
let birthdayWeekCache = { key: '', set: null };
|
||||
function birthdayWeekSet() {
|
||||
const now = new Date();
|
||||
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
|
||||
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
|
||||
const set = new Set();
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
birthdayWeekCache = { key, set };
|
||||
return set;
|
||||
}
|
||||
|
||||
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
|
||||
function isBirthdayActive(person) {
|
||||
if (!birthdayMode.enabled || !person) return false;
|
||||
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
|
||||
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
|
||||
}
|
||||
|
||||
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
|
||||
function birthdayTick() {
|
||||
const before = birthdayWeekCache.key;
|
||||
birthdayWeekSet();
|
||||
if (before && birthdayWeekCache.key !== before) void renderStage();
|
||||
}
|
||||
setInterval(birthdayTick, 15 * 60 * 1000);
|
||||
|
||||
function birthdayMakeParticle(w, h, intensity, seedY) {
|
||||
const ribbon = Math.random() < 0.55;
|
||||
return {
|
||||
x: Math.random() * w,
|
||||
y: seedY != null ? seedY : Math.random() * h,
|
||||
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
|
||||
h: ribbon ? 9 + Math.random() * 8 : 0,
|
||||
ribbon,
|
||||
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: -0.14 + Math.random() * 0.28,
|
||||
sway: 0.6 + Math.random() * 1.1,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
|
||||
};
|
||||
}
|
||||
|
||||
function birthdaySeedCanvas(c, w, h) {
|
||||
const base = c._intensity === 'subtle' ? 26 : 64;
|
||||
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
|
||||
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
|
||||
}
|
||||
|
||||
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
|
||||
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
|
||||
function birthdayStepCanvas(c) {
|
||||
const w = c.clientWidth;
|
||||
const h = c.clientHeight;
|
||||
if (!w || !h) return; // not laid out yet — try again next frame
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (c._w !== w || c._h !== h) {
|
||||
c.width = w * dpr;
|
||||
c.height = h * dpr;
|
||||
c._ctx = c.getContext('2d');
|
||||
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
c._w = w;
|
||||
c._h = h;
|
||||
birthdaySeedCanvas(c, w, h);
|
||||
}
|
||||
const ctx = c._ctx;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (const p of c._parts) {
|
||||
p.phase += 0.03;
|
||||
p.y += p.vy;
|
||||
p.x += Math.sin(p.phase) * p.sway;
|
||||
p.rot += p.vr;
|
||||
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = p.color;
|
||||
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function birthdayLoop() {
|
||||
for (const c of [...birthdayCanvases]) {
|
||||
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
|
||||
birthdayStepCanvas(c);
|
||||
}
|
||||
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
|
||||
}
|
||||
|
||||
function birthdayRegisterCanvas(canvas, intensity) {
|
||||
canvas._intensity = intensity;
|
||||
birthdayCanvases.add(canvas);
|
||||
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
|
||||
}
|
||||
|
||||
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
|
||||
function applyBirthdayEffects(tile, photoArea) {
|
||||
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
|
||||
tile.classList.add('birthday', `birthday-${intensity}`);
|
||||
|
||||
const confetti = document.createElement('canvas');
|
||||
confetti.className = 'birthday-confetti';
|
||||
confetti.setAttribute('aria-hidden', 'true');
|
||||
photoArea.appendChild(confetti);
|
||||
birthdayRegisterCanvas(confetti, intensity);
|
||||
}
|
||||
|
||||
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
||||
void parallaxAssets;
|
||||
const tile = document.createElement('article');
|
||||
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
||||
|
||||
photoArea.appendChild(nameBadge);
|
||||
photoArea.appendChild(removeBtn);
|
||||
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
|
||||
tile.appendChild(photoArea);
|
||||
return tile;
|
||||
}
|
||||
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
|
||||
async function renderAll() {
|
||||
await renderStage();
|
||||
await renderLibrary();
|
||||
renderBirthdayTestOptions();
|
||||
}
|
||||
|
||||
async function importNewPeople(added) {
|
||||
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
|
||||
source: 'local',
|
||||
pcoPersonId: '',
|
||||
pcoPlanPersonId: '',
|
||||
birthday: '',
|
||||
});
|
||||
}
|
||||
await renderLibrary();
|
||||
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
|
||||
}
|
||||
}
|
||||
|
||||
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
function formatBirthdayMmdd(mmdd) {
|
||||
const parts = String(mmdd || '').split('-');
|
||||
const mm = Number(parts[0]);
|
||||
const dd = Number(parts[1]);
|
||||
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
|
||||
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
|
||||
}
|
||||
|
||||
function setPcoBirthdayResult(message, tone) {
|
||||
if (!pcoBirthdayResult) return;
|
||||
pcoBirthdayResult.textContent = message || '';
|
||||
pcoBirthdayResult.style.display = message ? '' : 'none';
|
||||
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
|
||||
}
|
||||
|
||||
async function testPcoBirthdays() {
|
||||
setPcoControlsEnabled(false);
|
||||
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
|
||||
try {
|
||||
const r = await window.lineup.pcoTestBirthdays();
|
||||
if (!r || r.connected === false) {
|
||||
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!r.canRead) {
|
||||
const why = r.reason === 'network'
|
||||
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
|
||||
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
|
||||
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
|
||||
return;
|
||||
}
|
||||
// Access works. Report coverage across synced band members.
|
||||
if (r.total === 0) {
|
||||
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
|
||||
return;
|
||||
}
|
||||
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
|
||||
if (r.nextUp) {
|
||||
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
|
||||
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
|
||||
}
|
||||
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
|
||||
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
|
||||
} catch (error) {
|
||||
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
|
||||
} finally {
|
||||
setPcoControlsEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Birthday Mode controls ---------------- */
|
||||
|
||||
/** Reflect current birthdayMode settings onto the controls (called after load). */
|
||||
function syncBirthdayControls() {
|
||||
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
|
||||
if (bdayIntensity) {
|
||||
for (const b of bdayIntensity.querySelectorAll('button')) {
|
||||
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
|
||||
}
|
||||
|
||||
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
|
||||
function renderBirthdayTestOptions() {
|
||||
if (!bdayTestSelect) return;
|
||||
const previous = birthdayTestPersonId;
|
||||
bdayTestSelect.innerHTML = '<option value="">Off</option>';
|
||||
const seen = new Set();
|
||||
stageSlots.forEach((slot, index) => {
|
||||
if (!slot.personId || seen.has(slot.personId)) return;
|
||||
const person = getPerson(slot.personId);
|
||||
if (!person) return;
|
||||
seen.add(slot.personId);
|
||||
const option = document.createElement('option');
|
||||
option.value = person.id;
|
||||
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
|
||||
const label = String(slotLabelMemory[index] || '').trim();
|
||||
option.textContent = label ? `${name} — ${label}` : name;
|
||||
bdayTestSelect.appendChild(option);
|
||||
});
|
||||
// Keep the current override selected if that person is still on stage; otherwise drop it.
|
||||
if (previous && seen.has(previous)) {
|
||||
bdayTestSelect.value = previous;
|
||||
} else if (previous) {
|
||||
birthdayTestPersonId = '';
|
||||
bdayTestSelect.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Setup panel tabs ---------------- */
|
||||
|
||||
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
||||
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
|
||||
}
|
||||
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
|
||||
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
|
||||
if (name === 'pco') renderBirthdayTestOptions();
|
||||
}
|
||||
|
||||
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
||||
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
|
||||
void syncUpcomingPlanFromPco();
|
||||
});
|
||||
|
||||
btnPcoTestBirthdays.addEventListener('click', () => {
|
||||
revealChrome();
|
||||
void testPcoBirthdays();
|
||||
});
|
||||
|
||||
if (bdayEnabled) {
|
||||
bdayEnabled.addEventListener('change', () => {
|
||||
birthdayMode.enabled = bdayEnabled.checked;
|
||||
syncBirthdayControls();
|
||||
scheduleSave();
|
||||
void renderStage();
|
||||
});
|
||||
}
|
||||
if (bdayIntensity) {
|
||||
bdayIntensity.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-int]');
|
||||
if (!btn) return;
|
||||
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
|
||||
syncBirthdayControls();
|
||||
scheduleSave();
|
||||
void renderStage();
|
||||
});
|
||||
}
|
||||
if (bdayTestSelect) {
|
||||
bdayTestSelect.addEventListener('change', () => {
|
||||
birthdayTestPersonId = bdayTestSelect.value || '';
|
||||
void renderStage();
|
||||
});
|
||||
}
|
||||
|
||||
btnPcoDisconnect.addEventListener('click', async () => {
|
||||
revealChrome();
|
||||
setPcoControlsEnabled(false);
|
||||
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
|
||||
renderPcoPositionOptions();
|
||||
scheduleSave();
|
||||
setPcoStatus('Planning Center disconnected.');
|
||||
setPcoBirthdayResult('');
|
||||
} catch (error) {
|
||||
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
||||
} finally {
|
||||
|
||||
@@ -331,6 +331,105 @@ body {
|
||||
color: #9ea7bc;
|
||||
}
|
||||
|
||||
.pco-birthday-result {
|
||||
min-height: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pco-birthday-result:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Birthday Mode settings ---- */
|
||||
.bday-block {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid #23232c;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.bday-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bday-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #e7e7ef;
|
||||
}
|
||||
.bday-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #9ea7bc;
|
||||
}
|
||||
.bday-controls {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.bday-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bday-line-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8a8a99;
|
||||
min-width: 74px;
|
||||
}
|
||||
.bday-line-label em {
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #6d6d7a;
|
||||
}
|
||||
.seg-inline {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
background: #0e0e14;
|
||||
border: 1px solid #23232c;
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
}
|
||||
.seg-inline button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9ea7bc;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg-inline button[aria-pressed="true"] {
|
||||
background: #f5c451;
|
||||
color: #241a02;
|
||||
}
|
||||
.bday-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #c6cada;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bday-test-select {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
.bday-hint {
|
||||
font-size: 11px;
|
||||
color: #6d6d7a;
|
||||
}
|
||||
|
||||
.pco-position-builder {
|
||||
display: none;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -624,6 +723,33 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- Birthday Mode tile effects ---- */
|
||||
.tile.birthday-subtle {
|
||||
animation: bdayGlowSubtle 3.4s ease-in-out infinite;
|
||||
}
|
||||
.tile.birthday-full {
|
||||
animation: bdayGlowFull 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bdayGlowSubtle {
|
||||
0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); }
|
||||
50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); }
|
||||
}
|
||||
@keyframes bdayGlowFull {
|
||||
0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); }
|
||||
50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); }
|
||||
}
|
||||
|
||||
/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration
|
||||
the operator opts into, and freezing it leaves confetti stuck mid-fall. */
|
||||
|
||||
.birthday-confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.tile .remove {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"port": 24500,
|
||||
"adminPasscode": "",
|
||||
"repo": "michaelmcgary2008/ProdDash",
|
||||
"forge": "http://chronicles-ab.waterschurch.org:3000",
|
||||
"repo": "michael/ProdDash",
|
||||
"branch": "main"
|
||||
}
|
||||
|
||||
+333
-51
@@ -28,12 +28,55 @@ people running the booth depend on them.
|
||||
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.
|
||||
is fine; a program someone launches by hand is not. **A native helper —
|
||||
anything that is not JavaScript — ships prebuilt inside the module folder,
|
||||
for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on
|
||||
the machine running ProdDash** (see "Native helpers" below).
|
||||
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.
|
||||
|
||||
### Keeping the interface clean
|
||||
|
||||
A dashboard is read across a booth, at a glance, by someone doing three
|
||||
other things. These rules are what keep every tile looking like one product
|
||||
instead of six. They are as binding as the four above.
|
||||
|
||||
5. **No explanatory text in a tile's gear.** No `help` on an
|
||||
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
|
||||
words at most, one or two preferred** — "Slide count", "Item tags",
|
||||
"Running clock". A control that cannot be understood in three words is
|
||||
not a display choice; it belongs in `configSchema`, where `help` is
|
||||
welcome and expected. *One exception:* a `template` field may keep a
|
||||
single `help` line naming its placeholders, which nobody can guess.
|
||||
6. **Server-side settings are admin-only.** Anything the module's server
|
||||
acts on — a host, a credential, what to fetch, how often to poll — is
|
||||
`configSchema`. `instanceSchema` is display choices and nothing else:
|
||||
what this tile shows, and how big.
|
||||
7. **A tile setting changes that tile and nothing else.** Never another
|
||||
tile, never the module as a whole, never the server. If two tiles must
|
||||
agree on something, it is either admin config or page state the shell
|
||||
owns — not a setting each tile keeps its own copy of.
|
||||
8. **Offer toggles for the elements you draw.** Every tile should let the
|
||||
operator switch off the parts it does not need. **One to four toggles:**
|
||||
give them no `group` at all, so they sit unlabelled at the top of the
|
||||
gear. **Five or more:** one group named **Show**, `collapsed: true`,
|
||||
`columns: 2`. Nothing else goes in that group — colours, formats and
|
||||
text belong in groups of their own.
|
||||
9. **Title-bar controls are icons.** No `label` on `addButton` or
|
||||
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
|
||||
the icon. A small state indicator may ride alongside an icon — ProdCom's
|
||||
channel button shows a count while a filter is on — but a control is
|
||||
never named in the bar.
|
||||
10. **If a control needs words to explain it, it is not a title-bar
|
||||
control.** Put it in the gear. The bar is for actions whose icon and
|
||||
tooltip say everything: refresh, clear, jump to latest, bigger text.
|
||||
11. **One control per action.** Whatever has a dedicated title-bar button is
|
||||
not offered again in the gear, and the reverse. Text size is A− / A+ up
|
||||
there, never a "Text size" field; a timestamps button in the bar means
|
||||
no timestamps switch below it.
|
||||
|
||||
## The shape of a module
|
||||
|
||||
A module is one folder dropped into `modules/`:
|
||||
@@ -43,19 +86,86 @@ modules/<id>/
|
||||
├── module.json manifest (required)
|
||||
├── client.js ES module, client entry (required)
|
||||
├── server.js server entry (optional — only if you need server code)
|
||||
├── bin/ native helpers, prebuilt per platform (only if you need one)
|
||||
│ ├── <name>-macos-arm64
|
||||
│ └── <name>-win-x64.exe
|
||||
├── native/ their source and the script that builds every bin/ file
|
||||
└── ... assets (css, images…) served statically at /modules/<id>/
|
||||
```
|
||||
|
||||
**Installation is exactly this:** in the admin page, open **Available
|
||||
modules** and press **Install** — the module is downloaded from the ProdDash
|
||||
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.
|
||||
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
|
||||
with **no modules at all**: every one of them arrives this way, and the
|
||||
shell's own update never brings one along, so what is in git is exactly what
|
||||
runs on a booth. Developing locally? A git checkout runs whatever is in its
|
||||
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
|
||||
the data directory's `modules/` and restart. No shell code changes, ever. In
|
||||
a checkout the two 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 (in a checkout, the tree's copy is hidden instead, since that folder
|
||||
belongs to the repo); its settings are kept for a later reinstall.
|
||||
|
||||
### Native helpers
|
||||
|
||||
Some features need a program JavaScript cannot be: reading an audio input,
|
||||
decoding timecode, measuring sound. Rule 3 says the module owns that program;
|
||||
this section says how it ships: **already built, for every platform it runs
|
||||
on, inside the module folder.** The machine running ProdDash is a packaged
|
||||
app or the Windows bundle plus modules from the admin page's catalog. It has
|
||||
no compiler and never will — the catalog delivers exactly the folder that is
|
||||
in the repo, so the binaries live in git next to the code.
|
||||
|
||||
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
|
||||
macOS is welcome but optional. One file per platform in `bin/`, named for
|
||||
what it is:
|
||||
|
||||
| Platform | File |
|
||||
| --- | --- |
|
||||
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
|
||||
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
|
||||
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
|
||||
|
||||
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
|
||||
and `process.arch`; when there is no file for this machine, the feature
|
||||
says so — in `health()` and in the tile's `setStatus` — and everything else
|
||||
in the module keeps working. Never try to build it, never look for a copy
|
||||
elsewhere on the machine, never tell the operator to install anything.
|
||||
|
||||
```js
|
||||
const HELPERS = {
|
||||
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||
};
|
||||
const name = HELPERS[process.platform]?.[process.arch];
|
||||
const helper = name ? path.join(__dirname, 'bin', name) : '';
|
||||
if (!helper || !fs.existsSync(helper)) {
|
||||
// Timecode is off on this platform. Say it once, keep the clock running.
|
||||
}
|
||||
```
|
||||
|
||||
- **One contract, any language.** Every platform's binary speaks the same
|
||||
command line and output format, so `server.js` has one code path. What is
|
||||
behind it is your choice per platform — Swift against CoreAudio on macOS,
|
||||
C#, C or Rust on Windows — the module does not care.
|
||||
- **Source and build beside it.** Keep the source under `native/` with one
|
||||
script that produces every `bin/` file from a checkout, so any binary can be
|
||||
rebuilt from what is committed. A helper that cannot be rebuilt does not
|
||||
ship.
|
||||
- **Bump the module version whenever a binary changes**, like any other
|
||||
file — that is how installs learn about it.
|
||||
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
|
||||
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
|
||||
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
|
||||
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
|
||||
files the app writes carry no download mark, so SmartScreen never sees
|
||||
them. Permissions come from the launcher (see "Permissions macOS has to
|
||||
grant"), not from the binary.
|
||||
|
||||
A helper that ends up existing for one platform only is an exception made
|
||||
on purpose, not a default: say so in the module's README and in the health
|
||||
line, so a Windows booth knows why the feature is off.
|
||||
|
||||
## module.json
|
||||
|
||||
@@ -92,18 +202,19 @@ the app); its settings are kept for a later reinstall.
|
||||
| `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. |
|
||||
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||
| `style` | no | A stylesheet the shell injects once per module. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
|
||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||
|
||||
Both schemas map field names to specs:
|
||||
|
||||
```
|
||||
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
||||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
||||
"label": "Shown next to the field",
|
||||
"default": <value>,
|
||||
"group": "Section heading", // optional; see below
|
||||
@@ -115,10 +226,33 @@ Both schemas map field names to specs:
|
||||
}
|
||||
```
|
||||
|
||||
`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).
|
||||
`boolean` and `switch` are the same true/false value. In a tile's gear popover
|
||||
both render as a switch; in the admin page `switch` is the toggle and
|
||||
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
|
||||
fields depend on). **Label a switch with the noun of what it turns on** —
|
||||
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
|
||||
shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||||
so twice.
|
||||
`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`).
|
||||
`multiselect` is several picks from one list — the same `options` /
|
||||
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||||
an array of values. A saved value the list no longer offers stays checked and
|
||||
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||||
chosen shows every option on and saves nothing until someone flips one, so
|
||||
options that turn up later are on as well (give it no `default` then).
|
||||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||||
|
||||
`template` (tile settings) is a text with placeholders, built by dragging or
|
||||
clicking the variables the module offers into it: `"variables": [ { "value":
|
||||
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
|
||||
chips carry the names.
|
||||
|
||||
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||||
its newlines; one item per line is the convention (the `slack` module's quick
|
||||
replies). Admin config only.
|
||||
|
||||
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||
@@ -141,21 +275,47 @@ the group name:
|
||||
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`: guidance for the whole group. The admin page shows it as an
|
||||
ⓘ beside the group title (click or hover to read), so a group can carry a
|
||||
paragraph without pushing its fields down the page. Say where a value comes
|
||||
from, not what a field is.
|
||||
- `toggle`: the key of a `switch` field in this group. It renders in the
|
||||
group's title row, at the right, as the group's on/off; the rest of the group
|
||||
dims while it is off, and the field is not repeated in the body. This is how
|
||||
an integration is disabled without unmounting the module — ProPresenter
|
||||
timers, say, on a Sunday when ProPresenter isn't running.
|
||||
|
||||
`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.
|
||||
explanation there. **This is for `configSchema` only**: a tile's gear
|
||||
carries no explanations at all (rule 5), so a setting that needs one is
|
||||
telling you it belongs in admin.
|
||||
|
||||
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||
`group` works 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.
|
||||
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||||
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||||
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||||
by default, and fewer than five go in no group at all — see `pco-plan` for
|
||||
the long form and `slack` for the short one. Display choices belong there,
|
||||
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
|
||||
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||||
or pressing Escape — so a tile never has to be big enough to hold its own
|
||||
settings, and a change can be watched as it lands. Under everything you
|
||||
offer, the shell adds **Tile tint** — a colour the operator can wash that
|
||||
tile's chrome in, saved with the layout. It is not yours to declare and it
|
||||
never remounts you; don't offer one of your own.
|
||||
|
||||
A picker entry (see **Presenting multiple tiles**) may carry its own
|
||||
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
|
||||
instead of the module's. A tile that shows one ProPresenter timer has no use
|
||||
for a clock's format switches — give it a schema of what applies.
|
||||
|
||||
`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.
|
||||
It works the same in a tile's gear (`instanceSchema`): "Short day" and
|
||||
"Short month" appear once the clock's "Date" is on.
|
||||
|
||||
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||||
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||
@@ -272,7 +432,7 @@ export default function create({ root, moduleApi }) {
|
||||
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
||||
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
||||
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
||||
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
|
||||
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
|
||||
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
||||
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
|
||||
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
||||
@@ -287,32 +447,47 @@ bar, next to the shell's gear/close buttons:
|
||||
|
||||
```js
|
||||
const btn = moduleApi.header.addButton({
|
||||
icon: '<svg …>…</svg>', // inline SVG string, and/or
|
||||
label: 'A+', // short text
|
||||
title: 'Larger text', // hover tooltip
|
||||
icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
|
||||
label: 'Timers ▾', // short text
|
||||
title: 'Larger text', // tooltip — one or two words; the shell shows
|
||||
// it the moment the pointer arrives
|
||||
onClick() { … },
|
||||
});
|
||||
btn.classList.add('active'); // toggle styling for on/off buttons
|
||||
|
||||
const menu = moduleApi.header.addMenu({
|
||||
label: 'Channels ▾',
|
||||
title: 'Choose visible channels',
|
||||
title: 'Channels',
|
||||
build(menuEl) {
|
||||
// called on every open with an emptied menu element — fill it with
|
||||
// buttons (give them your scoped classes; the container is styled and
|
||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
||||
// positioned by the shell, hosted on the body under an element carrying
|
||||
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||
},
|
||||
});
|
||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||
```
|
||||
|
||||
Everything you add is removed automatically when your instance stops, so a
|
||||
remount never duplicates controls. Keep them compact (icon buttons, short
|
||||
labels): the title bar is 32 px tall and shared with the tile's name.
|
||||
remount never duplicates controls. **Icons only, no labels** (rule 9): the
|
||||
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||||
says what the icon means. Draw icons as inline SVG rather than characters —
|
||||
a glyph such as ✕ or + takes its size from whatever font each browser
|
||||
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||||
ride alongside an icon (ProdCom's channel button carries a count while a
|
||||
filter is on); a name may not.
|
||||
|
||||
One more thing to know: the user can hide the whole title bar with the
|
||||
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
||||
controls disappear with it, so nothing essential — status, reconnects,
|
||||
**One control per action** (rule 11). Whatever has a dedicated header button
|
||||
is not offered again in the gear popover: text size is − / + up here
|
||||
(persisted through `saveInstanceSettings({ textSize })`, read back with a
|
||||
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
|
||||
or newest-first toggle in the header means no such switch in the gear. The
|
||||
gear is for display choices that have no button of their own.
|
||||
|
||||
One more thing to know: the whole title bar folds away when the dashboard
|
||||
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
||||
displays) — it comes back only while the pointer is at the tile's top edge.
|
||||
Your controls fold away with it, so nothing essential — status, reconnects,
|
||||
live data — may exist *only* as a header control.
|
||||
|
||||
`sse(path, handlers)` handlers:
|
||||
@@ -327,24 +502,33 @@ const stream = moduleApi.sse('/stream', {
|
||||
// later: stream.close()
|
||||
```
|
||||
|
||||
Browsers' `EventSource` retries transient drops itself but gives up for good
|
||||
when a retry gets a completed non-SSE response — which is what a proxy's 502
|
||||
looks like while the upstream is down. The shell's `sse()` recreates closed
|
||||
streams on a 3-second timer until they work again, so **reconnect logic is
|
||||
free**: handle `open` (refresh/backfill your state; you may have missed
|
||||
events) and `error` (report degraded status), and you're resilient. Streams
|
||||
you forget to close are force-closed when the tile unmounts — but close your
|
||||
own in `stop()` anyway.
|
||||
Your route serves a plain SSE stream; how it reaches the page is the
|
||||
shell's business. A dashboard holds **one** connection for every stream on
|
||||
it (`GET /api/stream`, which the server feeds from your route over
|
||||
loopback) — browsers allow about six connections to a host and a tile per
|
||||
stream ran out of them — so `open` also fires whenever that shared
|
||||
connection is remade (a tile was added or removed) and whenever your route
|
||||
comes back after a drop. Browsers' `EventSource` retries transient drops
|
||||
itself but gives up for good when a retry gets a completed non-SSE
|
||||
response — which is what a proxy's 502 looks like while the upstream is
|
||||
down; the shell recreates a closed connection on a 3-second timer until it
|
||||
works again, so **reconnect logic is free**: handle `open` (refresh/backfill
|
||||
your state; you may have missed events) and `error` (report degraded
|
||||
status), and you're resilient. Streams you forget to close are force-closed
|
||||
when the tile unmounts — but close your own in `stop()` anyway.
|
||||
|
||||
### Lifecycle you must expect
|
||||
|
||||
- **Any number of instances.** Two transcript tiles with different filters
|
||||
is a feature. Keep all state inside `create()`'s closure — no module-level
|
||||
mutable state in `client.js`.
|
||||
- **Remounts are routine.** When the user edits the tile's gear settings the
|
||||
shell calls `stop()` and starts a fresh instance. When admin config
|
||||
changes, the shell calls your `onConfigChange(cfg)` if you export one,
|
||||
otherwise it remounts you. Either way you must come back cleanly.
|
||||
- **Remounts are routine.** Gear settings apply as the user changes them —
|
||||
there is no Apply button — so each change calls `stop()` and starts a fresh
|
||||
instance (typing is debounced, and an unchanged value remounts nothing).
|
||||
Starting cheaply matters: hold connections and timers in the closure and
|
||||
tear them down in `stop()`. When admin config changes, the shell calls your
|
||||
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
|
||||
way you must come back cleanly.
|
||||
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
||||
|
||||
### Rules for the client
|
||||
@@ -360,9 +544,15 @@ own in `stop()` anyway.
|
||||
|
||||
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
||||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||||
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
|
||||
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
|
||||
dark on most themes, white where the accent is deep), `--danger`,
|
||||
`--warn`. Derive tints with
|
||||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||||
every module look native on the dark, high-contrast booth theme.
|
||||
every module look native — and what makes the dashboard's **themes**
|
||||
(Admin → Theme) reach into every tile: a theme swaps those variables and
|
||||
nothing else. A colour a tile is configured with (a `color` instance
|
||||
setting, applied as a custom property on the tile) is set closer than the
|
||||
theme and wins over it, by design.
|
||||
|
||||
3. **No external network calls from the client.** Browsers talk only to
|
||||
ProdDash. Anything upstream (your device, service, API) goes through your
|
||||
@@ -382,7 +572,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
init({ config, log, shell }) {
|
||||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||||
// connection here. Return a handle:
|
||||
return {
|
||||
@@ -391,7 +581,7 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
routes({ config, log, shell }) {
|
||||
// Route table, mounted under /api/modules/<id>/
|
||||
return {
|
||||
'GET /state': (req, res) => { … },
|
||||
@@ -407,6 +597,9 @@ module.exports = {
|
||||
- `config` is your admin config: `configSchema` defaults overlaid with what
|
||||
the admin page saved — **including password fields**.
|
||||
- `log(...)` prefixes output with your module id.
|
||||
- `shell.port` is the port ProdDash is serving on — what you need to read
|
||||
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
|
||||
modules/<id>/…`). Never guess it.
|
||||
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
||||
the matched remainder (leading `/` included) and `req.search` is the query
|
||||
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
||||
@@ -443,7 +636,7 @@ module.exports = {
|
||||
return config.pages.map((page) => ({
|
||||
id: page.name, // stable id, stored with the tile
|
||||
name: page.name, // picker label AND the tile's title
|
||||
description: page.url, // optional line under the label
|
||||
description: page.url, // optional; the picker's tooltip
|
||||
settings: { page: page.name }, // preset instance settings
|
||||
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
||||
minSize: { w: 2, h: 2 },
|
||||
@@ -456,13 +649,31 @@ module.exports = {
|
||||
keep it fast (answer from state you already hold; don't fetch upstream on
|
||||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||
so a dead upstream can't break the picker.
|
||||
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
|
||||
tiles added from it — settings that fit that tile, nothing else.
|
||||
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
|
||||
for: a tile added under one of them (a layout saved by an older version of
|
||||
your module) gets this entry's settings too. A tile whose variant matches
|
||||
no entry and no alias falls back to the module's full `instanceSchema`.
|
||||
- Entry `description`s are tooltips in the + picker now, not visible lines —
|
||||
one short sentence.
|
||||
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||||
picker greys the entry out once it is placed. Use it for a control surface
|
||||
that would only confuse in duplicate — a send box, a master switch.
|
||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||
tile's title, and `moduleApi.variant` carries its `id`.
|
||||
- The tile keeps its variant id and title even if the entry later vanishes
|
||||
from your list (a timer deleted in ProPresenter, a page removed in admin).
|
||||
**Your client decides what that means** — show a clear "no longer
|
||||
configured" state rather than erroring.
|
||||
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
registry refresh. Never fall back to your default view in its place: five
|
||||
different timers turning into five "All timers" is worse than five gaps.
|
||||
- A client-only module can declare a fixed list as a `tiles` array in
|
||||
`module.json` instead (same entry shape).
|
||||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||||
@@ -493,6 +704,72 @@ per-client `res.write` in try/catch (drop the client on failure). The
|
||||
`propresenter-now-next` module is the reference implementation of this
|
||||
pattern.
|
||||
|
||||
## Working with other modules
|
||||
|
||||
Modules don't import each other. When one needs what another knows, it reads
|
||||
that module's **own server routes over loopback**, exactly as a browser would:
|
||||
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
|
||||
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
|
||||
rather than talking to ProPresenter a second time. A consumer must survive the
|
||||
provider being disabled, uninstalled, or restarting mid-service (404s and
|
||||
dropped connections are normal; retry with backoff and say so in `health()`).
|
||||
|
||||
### Offering timers to the Timers module
|
||||
|
||||
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
|
||||
clock, and any module that offers some. A module offers timers by:
|
||||
|
||||
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
|
||||
forwards `provides` in `/api/modules`, which is how the Timers module finds
|
||||
providers without a list anyone maintains.
|
||||
2. Serving `GET /timers` from its server routes, returning a snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pco-plan",
|
||||
"label": "PCO Plan",
|
||||
"updatedAt": 1757800000000,
|
||||
"timers": [
|
||||
{
|
||||
"id": "service",
|
||||
"label": "Service",
|
||||
"kind": "elapsed",
|
||||
"state": "running",
|
||||
"startedAt": 1757799000000,
|
||||
"elapsedMs": 1000000,
|
||||
"remainingMs": null,
|
||||
"targetMs": 4500000,
|
||||
"status": { "text": "Welcome & Announcements", "tone": "ok" },
|
||||
"detail": "Item 3 of 12"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
|
||||
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
|
||||
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
|
||||
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
|
||||
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
|
||||
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||
| `detail` | Optional second line. |
|
||||
|
||||
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
|
||||
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
|
||||
must be cheap to answer (serve from state you already hold; never call
|
||||
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
|
||||
(`event: timers`, `data:` the same object, on every change plus a heartbeat
|
||||
comment every 15 s); the consumer uses the stream when it exists and polls
|
||||
`/timers` otherwise. Only offer timers a production tech would want on a
|
||||
wall — the Timers module lets them hide any of them per tile, but every entry
|
||||
you add is a decision someone has to make.
|
||||
|
||||
## Complete minimal example
|
||||
|
||||
A working clock — copy-paste this as your starter. (A slightly fancier
|
||||
@@ -606,6 +883,11 @@ client-side failures (a tile that fails to start says so in the tile).
|
||||
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.
|
||||
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
|
||||
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
|
||||
section that loops twice, a song queued out of order, heartbeats.
|
||||
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
|
||||
the AbleSet module at host `127.0.0.1`, port `39071`.
|
||||
|
||||
**The resilience checklist** — every module must pass this before it's done:
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
||||
```bash
|
||||
./build.sh # build/ProdDash.app — the whole application
|
||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||
./build.sh --install # …and copy it to /Applications
|
||||
./build.sh --run # …and open it
|
||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||
@@ -68,9 +69,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
|
||||
against nodejs.org's published checksums either way.
|
||||
|
||||
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
|
||||
is the app's icon. A module's native helper is compiled during the build
|
||||
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
|
||||
ProdDash never needs a compiler.
|
||||
is the app's icon. The app carries no modules: they are installed from the
|
||||
admin page's catalog into the data directory, and a module's native helper
|
||||
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
|
||||
never needs a compiler.
|
||||
|
||||
## Signing
|
||||
|
||||
|
||||
@@ -103,6 +103,45 @@ enum ProdDash {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Go back to the copy ProdDash set aside before its last update.
|
||||
|
||||
A version that will not stay up is the one state this app cannot talk its
|
||||
way out of: the admin page went down with it, and installing a fresh
|
||||
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||
before it overwrites itself and drops it again once the new version has
|
||||
stayed up — meaning that folder exists only while an update is still
|
||||
unproven. This puts it back.
|
||||
|
||||
Returns the version restored, or nil when there is nothing to go back to.
|
||||
*/
|
||||
@discardableResult
|
||||
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||
let fm = FileManager.default
|
||||
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||
guard isRoot(previous) else { return nil }
|
||||
let version = shellVersion(root: previous)
|
||||
let named = version.isEmpty ? "(unknown version)" : version
|
||||
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||
do {
|
||||
try? fm.removeItem(at: broken)
|
||||
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||
try fm.moveItem(at: previous, to: root)
|
||||
makeWritable(root)
|
||||
try? fm.removeItem(at: broken)
|
||||
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||
return named
|
||||
} catch {
|
||||
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||
// Leave something runnable behind rather than nothing at all.
|
||||
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||
try? fm.moveItem(at: broken, to: root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||
/// exactly where ProdDash writes its own updates.
|
||||
private static func makeWritable(_ root: URL) {
|
||||
|
||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
/// One roll-back per bad update: a second would just swap copies forever.
|
||||
private var rolledBack = false
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
// Before giving up: if an update is still unproven, the copy it
|
||||
// replaced is sitting right there. Going back beats sitting dark,
|
||||
// and only once — if the old one will not run either, the problem
|
||||
// is not the update.
|
||||
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||
rolledBack = true
|
||||
crashTimes.removeAll()
|
||||
state = .stopped
|
||||
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||
Notifier.shared.post(title: "ProdDash rolled back",
|
||||
body: "The new version would not stay up. Back on \(restored).")
|
||||
refreshEnvironment()
|
||||
start()
|
||||
return
|
||||
}
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
|
||||
+15
-25
@@ -1,6 +1,8 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||
# Node runtime, with nothing to install first.
|
||||
# Node runtime, with nothing to install first. It carries no modules: those
|
||||
# come from the admin page's catalog into the data directory, and a module's
|
||||
# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md).
|
||||
#
|
||||
# ./build.sh build/ProdDash.app, self-contained
|
||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
||||
@@ -11,6 +13,8 @@
|
||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||
# it runs against the checkout. Seconds, not
|
||||
# minutes, while working on the launcher itself.
|
||||
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||
# silicon); the Node it embeds follows.
|
||||
#
|
||||
# Signing: a "Developer ID Application" certificate is used when one is
|
||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||
@@ -27,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
|
||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||
NODE_VERSION="v24.21.0"
|
||||
ARCH="arm64"
|
||||
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
case "$ARCH" in
|
||||
arm64) NODE_ARCH="arm64" ;;
|
||||
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
@@ -106,7 +115,7 @@ fi
|
||||
|
||||
# ── the server, and the Node that runs it ────────────────────────────────
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
||||
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||
@@ -125,25 +134,16 @@ if [[ $DEV -eq 0 ]]; then
|
||||
echo " embedding Node $NODE_VERSION"
|
||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||
|
||||
echo " embedding ProdDash $VERSION"
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
|
||||
find "$PAYLOAD" -name '.DS_Store' -delete
|
||||
|
||||
# A module's native helper is built here, so the Mac running ProdDash needs
|
||||
# no compiler: <name>.swift beside a module's files becomes <name>.
|
||||
for source in "$PAYLOAD"/modules/*/*.swift; do
|
||||
[[ -e "$source" ]] || continue
|
||||
helper="${source%.swift}"
|
||||
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
|
||||
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── signing ──────────────────────────────────────────────────────────────
|
||||
@@ -165,16 +165,6 @@ fi
|
||||
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
|
||||
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
|
||||
fi
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
for helper in "$PAYLOAD"/modules/*/*; do
|
||||
# A compiled helper is the extensionless executable beside a module's
|
||||
# sources — test the name, not the path, which has dots all over it.
|
||||
name=$(basename "$helper")
|
||||
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
|
||||
echo " signing $name"
|
||||
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
|
||||
done
|
||||
fi
|
||||
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep --strict "$APP" && echo " signature verifies"
|
||||
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/zsh
|
||||
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||
# its own Node runtime and a start script, so nothing has to be installed on
|
||||
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
|
||||
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
|
||||
#
|
||||
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||
#
|
||||
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||
# the server running and starts it again when the admin page applies an
|
||||
# update (the server exits with code 75 to ask for that).
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP_NAME="ProdDash"
|
||||
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||
NODE_VERSION="v24.21.0"
|
||||
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
CACHE_DIR=".cache"
|
||||
|
||||
for tool in curl shasum unzip zip; do
|
||||
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||
NAME="$APP_NAME-$VERSION-win-x64"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
|
||||
echo "ProdDash $VERSION — building the Windows bundle"
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||
|
||||
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||
ZIPFILE="$NODE_DIST.zip"
|
||||
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||
fi
|
||||
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||
# This binary ships under ProdDash's name — check it every build.
|
||||
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||
|
||||
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||
cp -R "$REPO_ROOT/public" "$STAGE/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
|
||||
find "$STAGE" -name '.DS_Store' -delete
|
||||
|
||||
# ── the start script and the read-me, with Windows line endings ──────────
|
||||
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||
|
||||
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||
@echo off
|
||||
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||
title ProdDash
|
||||
cd /d "%~dp0"
|
||||
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||
set PRODDASH_LAUNCHER=1
|
||||
:run
|
||||
"%~dp0node\node.exe" server.js
|
||||
set CODE=%ERRORLEVEL%
|
||||
if %CODE% EQU 75 goto run
|
||||
echo.
|
||||
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||
pause >nul
|
||||
BAT
|
||||
|
||||
crlf "$STAGE/README.txt" <<README
|
||||
ProdDash $VERSION for Windows (64-bit)
|
||||
=======================================
|
||||
|
||||
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||
|
||||
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||
(not Program Files: updates write into this folder).
|
||||
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||
dashboard runs; closing it stops the dashboard.
|
||||
- If Windows warns about the file, choose More info, then Run anyway.
|
||||
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||
private networks so other screens can reach the dashboard.
|
||||
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||
http://localhost:24500/admin.
|
||||
4. The bundle ships without modules. On the admin page, tick the ones this
|
||||
PC needs under Available modules and press Install selected; each one
|
||||
updates on its own from then on.
|
||||
|
||||
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||
so unzipping a new version or updating never loses them.
|
||||
|
||||
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||
shortcut to ProdDash.bat in the folder that opens.
|
||||
|
||||
Updates: the admin page's Update now fetches the new version into this
|
||||
folder and restarts the dashboard; the window stays open. A copy of the
|
||||
version it replaced is kept for a short while in case the new one will not
|
||||
start.
|
||||
README
|
||||
|
||||
# ── zip ──────────────────────────────────────────────────────────────────
|
||||
echo " zipping"
|
||||
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||
@@ -0,0 +1,727 @@
|
||||
/* AbleSet — client part.
|
||||
|
||||
One factory draws every AbleSet tile; `moduleApi.variant` says which one
|
||||
(the server's tiles() list). All of them read the same stream: `state`
|
||||
whenever something changes, `pos` five times a second while playing.
|
||||
Between packets the playhead and the countdowns are extrapolated from the
|
||||
tempo and the clock, so bars glide instead of stepping. */
|
||||
|
||||
const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status'];
|
||||
|
||||
/* AbleSet names its colours after Live's palette; these are close enough to
|
||||
read as the same colour on a dark tile. Unknown names draw no colour. */
|
||||
const NAMED_COLORS = {
|
||||
red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399',
|
||||
mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6',
|
||||
indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6',
|
||||
rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5',
|
||||
};
|
||||
|
||||
const ICON_STRIP = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
|
||||
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */
|
||||
function fmt(sec) {
|
||||
if (sec == null || !Number.isFinite(sec)) return '--:--';
|
||||
const s = Math.max(0, Math.floor(sec + 1e-6));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`;
|
||||
}
|
||||
|
||||
function fmtBpm(t) {
|
||||
if (t == null) return '--';
|
||||
return (Math.round(t * 100) / 100).toString();
|
||||
}
|
||||
|
||||
function colorFor(value) {
|
||||
if (!value) return null;
|
||||
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
|
||||
return NAMED_COLORS[String(value).toLowerCase()] || null;
|
||||
}
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant
|
||||
: moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing
|
||||
const prefs = () => moduleApi.instanceSettings || {};
|
||||
const on = (k) => prefs()[k] !== false;
|
||||
|
||||
let state = null;
|
||||
let pos = { beats: null, remainingSong: null, remainingSet: null };
|
||||
let posAt = 0;
|
||||
let stream = null;
|
||||
let raf = 0;
|
||||
let resizeObserver = null;
|
||||
let tickTimer = null;
|
||||
|
||||
/* ── moving values, extrapolated to this frame ─────────────────── */
|
||||
|
||||
const moving = () => Boolean(state && state.reachable && state.playing);
|
||||
const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0);
|
||||
|
||||
function beatsNow() {
|
||||
if (pos.beats == null) return null;
|
||||
const t = state?.tempo;
|
||||
return t ? pos.beats + dt() * (t / 60) : pos.beats;
|
||||
}
|
||||
const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt()));
|
||||
const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt()));
|
||||
|
||||
function fraction(start, end, b) {
|
||||
if (start == null || end == null || b == null || end <= start) return null;
|
||||
return Math.min(1, Math.max(0, (b - start) / (end - start)));
|
||||
}
|
||||
|
||||
function songsLeft() {
|
||||
if (!state?.song || state.song.index == null) return null;
|
||||
return Math.max(0, state.setlist.songs.length - state.song.index - 1);
|
||||
}
|
||||
|
||||
/** Beats per bar from the time signature (Live counts quarter notes). */
|
||||
function beatsPerBar() {
|
||||
const ts = state?.timeSig;
|
||||
return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4;
|
||||
}
|
||||
|
||||
/** Bar and beat within the song, 1-based. */
|
||||
function barBeat() {
|
||||
const b = beatsNow();
|
||||
const start = state?.song?.start ?? 0;
|
||||
if (b == null) return null;
|
||||
const rel = Math.max(0, b - start);
|
||||
const bpb = beatsPerBar();
|
||||
const ts = state?.timeSig || [4, 4];
|
||||
const beatLen = 4 / (ts[1] || 4);
|
||||
const bar = Math.floor(rel / bpb) + 1;
|
||||
const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1;
|
||||
return { bar, beat, of: ts[0] || 4 };
|
||||
}
|
||||
|
||||
function tone(sec) {
|
||||
if (sec == null || !moving()) return '';
|
||||
const warn = Number(prefs().warnAt ?? 30);
|
||||
const alert = Number(prefs().alertAt ?? 10);
|
||||
if (sec <= alert) return 'alert';
|
||||
if (sec <= warn) return 'warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ── fitting one-line text to its box ──────────────────────────── */
|
||||
|
||||
/** Shrink a nowrap element until it fits the width its parent gives it. */
|
||||
function fit(el) {
|
||||
if (!el || !el.isConnected) return;
|
||||
el.style.fontSize = '';
|
||||
const max = parseFloat(getComputedStyle(el).fontSize) || 16;
|
||||
const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth;
|
||||
let size = max;
|
||||
for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) {
|
||||
size = Math.max(9, size * (avail / el.scrollWidth) * 0.98);
|
||||
el.style.fontSize = `${size}px`;
|
||||
}
|
||||
}
|
||||
const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit);
|
||||
|
||||
/** Set text, refitting only when it changed. */
|
||||
function text(el, value) {
|
||||
if (!el) return;
|
||||
const v = String(value ?? '');
|
||||
if (el.textContent === v) return;
|
||||
el.textContent = v;
|
||||
if (el.hasAttribute('data-fit')) fit(el);
|
||||
}
|
||||
|
||||
function show(el, visible) {
|
||||
if (el) el.hidden = !visible;
|
||||
}
|
||||
|
||||
/* ── skeleton per view ─────────────────────────────────────────── */
|
||||
|
||||
root.innerHTML = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
|
||||
const shell = root.firstElementChild;
|
||||
const body = shell.querySelector('.as-body');
|
||||
const offlineEl = shell.querySelector('.as-offline');
|
||||
const $ = (sel) => body.querySelector(sel);
|
||||
|
||||
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
|
||||
|
||||
const SKELETONS = {
|
||||
now: `
|
||||
<div class="as-title-box"><div class="as-title" data-fit></div></div>
|
||||
<div class="as-badges">
|
||||
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
|
||||
</div>
|
||||
${BAR}
|
||||
<div class="as-next">
|
||||
<div class="as-next-label">Next:</div>
|
||||
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
|
||||
<div class="as-left"></div>
|
||||
</div>`,
|
||||
sections: `
|
||||
<div class="as-song-name" data-fit></div>
|
||||
<div class="as-viewport"><div class="as-track"></div></div>`,
|
||||
setlist: `
|
||||
<div class="as-set-head"><span class="as-set-name"></span></div>
|
||||
<div class="as-list"></div>
|
||||
<div class="as-set-foot"></div>`,
|
||||
transport: `
|
||||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||||
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
|
||||
tempo: `
|
||||
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
|
||||
<div class="as-sub"><span class="as-meter"></span></div>
|
||||
<div class="as-beats"></div>`,
|
||||
'song-time': `
|
||||
<div class="as-caption" data-fit></div>
|
||||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||||
<div class="as-sub as-sub-time"></div>
|
||||
${BAR}`,
|
||||
'set-time': `
|
||||
<div class="as-caption" data-fit>Set</div>
|
||||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||||
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
|
||||
section: `
|
||||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||||
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
|
||||
<div class="as-sect-bar"><div></div></div>`,
|
||||
'next-song': `
|
||||
<div class="as-caption">Next</div>
|
||||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||||
<div class="as-sub"><span class="as-next-dur"></span></div>
|
||||
<div class="as-queued" hidden></div>`,
|
||||
status: `<div class="as-rows"></div>`,
|
||||
};
|
||||
body.innerHTML = SKELETONS[view] || '';
|
||||
|
||||
/* ── shared pieces ─────────────────────────────────────────────── */
|
||||
|
||||
/** The three-part song bar: played sections, this section, the playhead. */
|
||||
function paintBar(bar) {
|
||||
if (!bar) return;
|
||||
const s = state?.song;
|
||||
const sect = state?.section;
|
||||
const b = beatsNow();
|
||||
const len = s && s.end > s.start ? s.end - s.start : 0;
|
||||
const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0);
|
||||
const sStart = sect && sect.start != null ? at(sect.start) : 0;
|
||||
const sEnd = sect && sect.end != null ? at(sect.end) : 0;
|
||||
const head = b != null ? at(b) : 0;
|
||||
bar.style.setProperty('--done', `${sStart}%`);
|
||||
bar.style.setProperty('--sect-l', `${sStart}%`);
|
||||
bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`);
|
||||
const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0;
|
||||
bar.style.setProperty('--played', `${inSect}%`);
|
||||
}
|
||||
|
||||
function songTint(el, hex) {
|
||||
if (hex) el.style.setProperty('--song', hex);
|
||||
else el.style.removeProperty('--song');
|
||||
el.classList.toggle('has-song-color', Boolean(hex));
|
||||
}
|
||||
|
||||
/* ── NOW PLAYING ───────────────────────────────────────────────── */
|
||||
|
||||
const nowView = {
|
||||
render() {
|
||||
const s = state;
|
||||
const song = s.song;
|
||||
text($('.as-title'), song ? song.name : s.setlist.name || 'No song');
|
||||
shell.classList.toggle('is-playing', moving());
|
||||
songTint(shell, on('songColors') && song ? colorFor(song.color) : null);
|
||||
text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : '');
|
||||
text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
|
||||
text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : '');
|
||||
const sect = s.section;
|
||||
text($('.b-section'), sect ? sect.name : '');
|
||||
const sc = on('songColors') && sect ? colorFor(sect.color) : null;
|
||||
$('.b-section').style.color = sc || '';
|
||||
show($('.b-tempo'), on('showTempo') && s.tempo != null);
|
||||
show($('.b-meter'), on('showMeter') && Boolean(s.timeSig));
|
||||
show($('.b-dur'), on('showDuration') && song?.duration != null);
|
||||
show($('.b-clock'), on('showClock') && pos.remainingSong != null);
|
||||
show($('.b-section'), on('showSection') && Boolean(sect?.name));
|
||||
show($('.as-bar'), on('showProgress') && Boolean(song));
|
||||
const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong;
|
||||
text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:');
|
||||
$('.as-next').classList.toggle('is-queued', s.queued?.type === 'song');
|
||||
text($('.as-next-title'), next ? next.name : 'End of set');
|
||||
show($('.as-next-label'), on('showNext'));
|
||||
show($('.as-next-box'), on('showNext'));
|
||||
show($('.as-left'), on('showLeft') && songsLeft() != null);
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const left = songLeft();
|
||||
const clockMode = prefs().clock || 'remaining';
|
||||
const dur = state.song?.duration;
|
||||
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
|
||||
const clock = clockMode === 'elapsed' ? fmt(elapsed)
|
||||
: clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`;
|
||||
const c = $('.b-clock');
|
||||
text(c, clock);
|
||||
c.dataset.tone = tone(left);
|
||||
paintBar($('.as-bar'));
|
||||
const n = songsLeft();
|
||||
if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */
|
||||
|
||||
const WHEEL_STEP = 30; // degrees between wheel faces
|
||||
let sectionKey = '';
|
||||
let wheelButton = null;
|
||||
|
||||
const sectionsView = {
|
||||
style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; },
|
||||
build() {
|
||||
const track = $('.as-track');
|
||||
const list = state.sections || [];
|
||||
const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`;
|
||||
if (key === sectionKey && track.children.length === list.length) return;
|
||||
sectionKey = key;
|
||||
track.innerHTML = list.map((x, i) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|
||||
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
|
||||
this.jump();
|
||||
},
|
||||
/** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */
|
||||
jump() {
|
||||
const track = $('.as-track');
|
||||
track.classList.add('no-anim');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim')));
|
||||
},
|
||||
render() {
|
||||
const wheel = this.style() === 'wheel';
|
||||
shell.classList.toggle('is-wheel', wheel);
|
||||
shell.classList.toggle('is-playing', moving());
|
||||
text($('.as-song-name'), state.song ? state.song.name : '');
|
||||
show($('.as-song-name'), on('showSong') && Boolean(state.song));
|
||||
this.build();
|
||||
const cur = state.section?.index ?? -1;
|
||||
const nxt = state.nextSection?.index ?? -1;
|
||||
const pills = $('.as-track').children;
|
||||
for (const el of pills) {
|
||||
const i = Number(el.dataset.i);
|
||||
if (Number.isNaN(i)) continue;
|
||||
el.classList.toggle('is-past', i < cur);
|
||||
el.classList.toggle('is-current', i === cur);
|
||||
el.classList.toggle('is-next', i === nxt);
|
||||
const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null;
|
||||
if (c) el.style.setProperty('--sect', c);
|
||||
else el.style.removeProperty('--sect');
|
||||
el.classList.toggle('has-color', Boolean(c));
|
||||
}
|
||||
this.place();
|
||||
this.frame();
|
||||
},
|
||||
place() {
|
||||
const track = $('.as-track');
|
||||
const vp = $('.as-viewport');
|
||||
const cur = Math.max(0, state.section?.index ?? 0);
|
||||
const pills = [...track.children];
|
||||
if (this.style() === 'wheel') {
|
||||
const h = vp.clientHeight / 3.4;
|
||||
const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180));
|
||||
track.style.setProperty('--face-h', `${h}px`);
|
||||
pills.forEach((el, i) => {
|
||||
const d = i - cur;
|
||||
el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`;
|
||||
el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3);
|
||||
});
|
||||
track.style.transform = `translateZ(${-r}px)`;
|
||||
} else {
|
||||
pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; });
|
||||
const el = pills[cur] || pills[0];
|
||||
if (!el) return;
|
||||
const center = el.offsetLeft + el.offsetWidth / 2;
|
||||
track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`;
|
||||
}
|
||||
},
|
||||
frame() {
|
||||
const cur = state.section?.index ?? -1;
|
||||
const el = $('.as-track').children[cur];
|
||||
if (!el) return;
|
||||
const f = fraction(state.section.start, state.section.end, beatsNow());
|
||||
el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%');
|
||||
},
|
||||
toggleStyle() {
|
||||
const next = this.style() === 'wheel' ? 'strip' : 'wheel';
|
||||
moduleApi.saveInstanceSettings({ style: next });
|
||||
this.syncButton();
|
||||
this.jump();
|
||||
if (state) this.render();
|
||||
},
|
||||
syncButton() {
|
||||
if (!wheelButton) return;
|
||||
const wheel = this.style() === 'wheel';
|
||||
wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL;
|
||||
wheelButton.title = wheel ? 'Strip view' : 'Wheel view';
|
||||
},
|
||||
};
|
||||
|
||||
/* ── SETLIST ───────────────────────────────────────────────────── */
|
||||
|
||||
let listKey = '';
|
||||
let lastScrolled = -2;
|
||||
|
||||
const setlistView = {
|
||||
render() {
|
||||
const s = state;
|
||||
text($('.as-set-name'), s.setlist.name || 'Setlist');
|
||||
const list = $('.as-list');
|
||||
const songs = s.setlist.songs;
|
||||
const key = JSON.stringify([songs, on('showDurations'), on('songColors')]);
|
||||
if (key !== listKey) {
|
||||
listKey = key;
|
||||
lastScrolled = -2;
|
||||
list.innerHTML = songs.map((x, i) => {
|
||||
const c = on('songColors') ? colorFor(x.color) : null;
|
||||
return `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
|
||||
<span class="as-row-fill"></span>
|
||||
${c ? '<span class="as-swatch"></span>' : ''}
|
||||
<span class="as-row-name">${esc(x.name)}</span>
|
||||
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('') || '<div class="as-empty">No setlist</div>';
|
||||
}
|
||||
const cur = s.song?.index ?? -1;
|
||||
const nxt = s.nextSong?.index ?? -1;
|
||||
const q = s.queued?.type === 'song' ? s.queued.songIndex : -1;
|
||||
for (const row of list.children) {
|
||||
const i = Number(row.dataset.i);
|
||||
if (Number.isNaN(i)) continue;
|
||||
row.classList.toggle('is-past', i < cur);
|
||||
row.classList.toggle('is-current', i === cur);
|
||||
row.classList.toggle('is-next', i === nxt && q < 0);
|
||||
row.classList.toggle('is-queued', i === q);
|
||||
row.hidden = !on('showPlayed') && i < cur;
|
||||
}
|
||||
shell.classList.toggle('is-playing', moving());
|
||||
if (cur !== lastScrolled && list.children[cur]) {
|
||||
lastScrolled = cur;
|
||||
const row = list.children[cur];
|
||||
list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' });
|
||||
}
|
||||
show($('.as-set-foot'), on('showTotal'));
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const row = $('.as-list').children[state.song?.index ?? -1];
|
||||
if (row && state.song) {
|
||||
const f = fraction(state.song.start, state.song.end, beatsNow());
|
||||
row.style.setProperty('--p', `${(f ?? 0) * 100}%`);
|
||||
}
|
||||
const n = songsLeft();
|
||||
text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── TRANSPORT ─────────────────────────────────────────────────── */
|
||||
|
||||
const transportView = {
|
||||
render() {
|
||||
const s = state;
|
||||
let word = 'Stopped';
|
||||
let t = 'muted';
|
||||
if (s.loading) { word = 'Loading'; t = 'warn'; }
|
||||
else if (!s.live) { word = 'No Live'; t = 'alert'; }
|
||||
else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; }
|
||||
else if (s.countingIn) { word = 'Count-in'; t = 'warn'; }
|
||||
else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; }
|
||||
else if (s.playing) { word = 'Playing'; t = 'ok'; }
|
||||
else if (s.paused) { word = 'Paused'; t = 'warn'; }
|
||||
text($('.as-big'), word);
|
||||
shell.dataset.tone = t;
|
||||
const loop = s.sectionLoop?.mode
|
||||
? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}`
|
||||
: s.loop.inSection ? 'Section loop' : '';
|
||||
text($('.as-loop'), loop);
|
||||
show($('.as-loop'), on('showLoop') && Boolean(loop));
|
||||
show($('.as-pos'), on('showPosition'));
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const bb = barBeat();
|
||||
text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : '');
|
||||
},
|
||||
};
|
||||
|
||||
/* ── TEMPO ─────────────────────────────────────────────────────── */
|
||||
|
||||
let beatCount = 0;
|
||||
const tempoView = {
|
||||
render() {
|
||||
const s = state;
|
||||
text($('.as-bpm'), fmtBpm(s.tempo));
|
||||
fit($('.as-big'));
|
||||
text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
|
||||
show($('.as-meter'), on('showMeter'));
|
||||
const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4));
|
||||
const beats = $('.as-beats');
|
||||
if (beatCount !== n) {
|
||||
beatCount = n;
|
||||
beats.innerHTML = Array.from({ length: n }, (_, i) => `<i class="${i === 0 ? 'is-down' : ''}"></i>`).join('');
|
||||
}
|
||||
show(beats, on('showBeats'));
|
||||
shell.classList.toggle('is-playing', moving());
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const bb = moving() ? barBeat() : null;
|
||||
const lights = $('.as-beats').children;
|
||||
for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── SONG TIME ─────────────────────────────────────────────────── */
|
||||
|
||||
const songTimeView = {
|
||||
render() {
|
||||
const song = state.song;
|
||||
text($('.as-caption'), song ? song.name : 'Song');
|
||||
show($('.as-caption'), on('showSong'));
|
||||
show($('.as-bar'), on('showProgress') && Boolean(song));
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const left = songLeft();
|
||||
const dur = state.song?.duration;
|
||||
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
|
||||
const mode = prefs().clock || 'remaining';
|
||||
text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`);
|
||||
text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : '');
|
||||
shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted');
|
||||
paintBar($('.as-bar'));
|
||||
},
|
||||
};
|
||||
|
||||
/* ── SET TIME ──────────────────────────────────────────────────── */
|
||||
|
||||
const setTimeView = {
|
||||
render() {
|
||||
text($('.as-caption'), state.setlist.name || 'Set');
|
||||
show($('.as-left-count'), on('showLeft'));
|
||||
show($('.as-end'), on('showEnd'));
|
||||
shell.dataset.tone = moving() ? 'ok' : 'muted';
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const left = setLeft();
|
||||
text($('.as-big'), fmt(left));
|
||||
const n = songsLeft();
|
||||
text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`);
|
||||
const end = left != null ? new Date(Date.now() + left * 1000) : null;
|
||||
text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : '');
|
||||
},
|
||||
};
|
||||
|
||||
/* ── SECTION ───────────────────────────────────────────────────── */
|
||||
|
||||
const sectionView = {
|
||||
render() {
|
||||
const sect = state.section;
|
||||
text($('.as-big'), sect ? sect.name : '—');
|
||||
const c = on('sectionColors') && sect ? colorFor(sect.color) : null;
|
||||
songTint(shell, c);
|
||||
const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name;
|
||||
text($('.as-next-sect'), nx ? `→ ${nx}` : '');
|
||||
show($('.as-next-sect'), on('showNext') && Boolean(nx));
|
||||
show($('.as-count'), on('showCount'));
|
||||
show($('.as-sect-bar'), on('showCount'));
|
||||
this.frame();
|
||||
},
|
||||
frame() {
|
||||
const sect = state.section;
|
||||
const b = beatsNow();
|
||||
const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection);
|
||||
let label = '';
|
||||
let t = moving() ? 'ok' : 'muted';
|
||||
if (sect && sect.end != null && b != null) {
|
||||
const leftBeats = Math.max(0, sect.end - b);
|
||||
const bpb = beatsPerBar();
|
||||
const beatLen = 4 / (state.timeSig?.[1] || 4);
|
||||
if (looping) { label = 'Looping'; t = 'warn'; }
|
||||
else if (leftBeats <= bpb + 1e-6) {
|
||||
const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6));
|
||||
label = `${n} beat${n === 1 ? '' : 's'}`;
|
||||
if (moving()) t = 'warn';
|
||||
} else {
|
||||
const n = Math.ceil(leftBeats / bpb - 1e-6);
|
||||
label = `${n} bars`;
|
||||
}
|
||||
const f = fraction(sect.start, sect.end, b);
|
||||
$('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`);
|
||||
}
|
||||
text($('.as-count'), label);
|
||||
shell.dataset.tone = t;
|
||||
},
|
||||
};
|
||||
|
||||
/* ── NEXT SONG ─────────────────────────────────────────────────── */
|
||||
|
||||
const nextSongView = {
|
||||
render() {
|
||||
const n = state.nextSong;
|
||||
text($('.as-big'), n ? n.name : 'End of set');
|
||||
text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : '');
|
||||
show($('.as-next-dur'), on('showDuration') && n?.duration != null);
|
||||
const q = state.queued;
|
||||
const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : '';
|
||||
text($('.as-queued'), qText);
|
||||
show($('.as-queued'), on('showQueued') && Boolean(q));
|
||||
shell.dataset.tone = q ? 'warn' : n ? '' : 'muted';
|
||||
},
|
||||
frame() {},
|
||||
};
|
||||
|
||||
/* ── STATUS ────────────────────────────────────────────────────── */
|
||||
|
||||
const statusView = {
|
||||
render() {
|
||||
const s = state;
|
||||
const rows = [
|
||||
['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'],
|
||||
['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'],
|
||||
];
|
||||
if (s.loading) rows.push(['Project', 'Loading…', 'warn']);
|
||||
if (s.recording) rows.push(['Recording', 'On', 'alert']);
|
||||
if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']);
|
||||
if (on('showAudio')) {
|
||||
if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']);
|
||||
for (const a of s.audio) {
|
||||
const map = {
|
||||
armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'],
|
||||
};
|
||||
const [label, t] = map[a.status] || [a.status || 'Connected', ''];
|
||||
rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']);
|
||||
}
|
||||
}
|
||||
$('.as-rows').innerHTML = rows.map(([k, v, t]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).join('');
|
||||
const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok';
|
||||
shell.dataset.tone = worst;
|
||||
},
|
||||
frame() {},
|
||||
};
|
||||
|
||||
const VIEW_IMPL = {
|
||||
now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView,
|
||||
'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView,
|
||||
};
|
||||
const impl = VIEW_IMPL[view];
|
||||
|
||||
/* ── drawing loop ──────────────────────────────────────────────── */
|
||||
|
||||
function render() {
|
||||
if (!impl || !state) return;
|
||||
const offline = !state.reachable && view !== 'status';
|
||||
shell.classList.toggle('is-offline', offline);
|
||||
show(offlineEl, offline);
|
||||
if (offline) {
|
||||
text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline');
|
||||
}
|
||||
impl.render();
|
||||
loop();
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (raf || !moving()) return;
|
||||
const step = () => {
|
||||
raf = 0;
|
||||
if (!state || !moving()) return;
|
||||
impl.frame();
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function reportStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin');
|
||||
if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer');
|
||||
if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected');
|
||||
return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet');
|
||||
}
|
||||
|
||||
function applyState(next) {
|
||||
if (!next || typeof next !== 'object') return;
|
||||
takePos(next.pos);
|
||||
state = next;
|
||||
reportStatus();
|
||||
render();
|
||||
}
|
||||
|
||||
/* The page's one stream is shared, so a tile mounted onto it (a gear
|
||||
change remounts the tile) joins mid-flow and hears nothing until
|
||||
AbleSet next changes — which, stopped, may be never. Ask for the state. */
|
||||
let stopped = false;
|
||||
async function backfill() {
|
||||
try {
|
||||
const res = await moduleApi.fetch('/state');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
if (!stopped) applyState(body.state);
|
||||
} catch { /* the stream's error handler reports the outage */ }
|
||||
}
|
||||
|
||||
function takePos(p) {
|
||||
if (!p) return;
|
||||
pos = p;
|
||||
posAt = performance.now();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (!impl) return;
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (view === 'sections') {
|
||||
wheelButton = moduleApi.header.addButton({
|
||||
icon: ICON_WHEEL,
|
||||
title: 'Wheel view',
|
||||
onClick: () => sectionsView.toggleStyle(),
|
||||
});
|
||||
sectionsView.syncButton();
|
||||
}
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() { backfill(); },
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
|
||||
},
|
||||
events: {
|
||||
state(e) {
|
||||
let next;
|
||||
try { next = JSON.parse(e.data); } catch { return; }
|
||||
applyState(next);
|
||||
},
|
||||
pos(e) {
|
||||
try { takePos(JSON.parse(e.data)); } catch { return; }
|
||||
if (state && !moving()) impl.frame();
|
||||
loop();
|
||||
},
|
||||
},
|
||||
});
|
||||
backfill();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAll();
|
||||
if (view === 'sections' && state) sectionsView.place();
|
||||
});
|
||||
resizeObserver.observe(shell);
|
||||
// Stopped tiles still have a clock to keep honest (Set Time's end time).
|
||||
tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000);
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (stream) stream.close();
|
||||
stream = null;
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
clearInterval(tickTimer);
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "ableset",
|
||||
"name": "AbleSet",
|
||||
"version": "1.0.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "The song AbleSet is playing — tempo, time left, sections, setlist and status",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Following AbleSet on this network"
|
||||
}
|
||||
],
|
||||
"provides": ["timers"],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 1,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 8,
|
||||
"h": 4
|
||||
},
|
||||
"configSchema": {
|
||||
"ableset": {
|
||||
"type": "endpoint",
|
||||
"label": "AbleSet",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 39051
|
||||
},
|
||||
"help": "The Mac running AbleSet (e.g. AB-Tracks.local). 39051 is AbleSet's OSC port; it only changes if someone changed it in AbleSet's settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The slice of OSC 1.0 AbleSet speaks — messages and bundles with int32,
|
||||
* float32, string, blob, true/false/nil — encoded and decoded with Buffer
|
||||
* alone, so the module needs nothing outside Node.
|
||||
*/
|
||||
|
||||
function pad4(n) {
|
||||
return (n + 3) & ~3;
|
||||
}
|
||||
|
||||
function encodeString(s) {
|
||||
const raw = Buffer.from(String(s), 'utf8');
|
||||
const out = Buffer.alloc(pad4(raw.length + 1));
|
||||
raw.copy(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One message. Arguments are typed by their JS value: integers are int32,
|
||||
* other numbers float32, booleans T/F, null nil, everything else a string.
|
||||
*/
|
||||
function encodeMessage(address, args = []) {
|
||||
let tags = ',';
|
||||
const parts = [];
|
||||
for (const a of args) {
|
||||
if (a === null || a === undefined) {
|
||||
tags += 'N';
|
||||
} else if (typeof a === 'boolean') {
|
||||
tags += a ? 'T' : 'F';
|
||||
} else if (typeof a === 'number' && Number.isInteger(a)) {
|
||||
tags += 'i';
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeInt32BE(a);
|
||||
parts.push(b);
|
||||
} else if (typeof a === 'number') {
|
||||
tags += 'f';
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeFloatBE(a);
|
||||
parts.push(b);
|
||||
} else {
|
||||
tags += 's';
|
||||
parts.push(encodeString(a));
|
||||
}
|
||||
}
|
||||
return Buffer.concat([encodeString(address), encodeString(tags), ...parts]);
|
||||
}
|
||||
|
||||
function readString(buf, at) {
|
||||
let end = at;
|
||||
while (end < buf.length && buf[end] !== 0) end += 1;
|
||||
if (end >= buf.length) throw new Error('unterminated OSC string');
|
||||
return { value: buf.toString('utf8', at, end), next: pad4(end + 1) };
|
||||
}
|
||||
|
||||
function decodeMessage(buf, out) {
|
||||
const addr = readString(buf, 0);
|
||||
let at = addr.next;
|
||||
const args = [];
|
||||
if (at < buf.length && buf[at] === 0x2c) {
|
||||
const tagStr = readString(buf, at);
|
||||
at = tagStr.next;
|
||||
for (const t of tagStr.value.slice(1)) {
|
||||
switch (t) {
|
||||
case 'i': args.push(buf.readInt32BE(at)); at += 4; break;
|
||||
case 'f': args.push(buf.readFloatBE(at)); at += 4; break;
|
||||
case 'd': args.push(buf.readDoubleBE(at)); at += 8; break;
|
||||
case 'h': args.push(Number(buf.readBigInt64BE(at))); at += 8; break;
|
||||
case 's': case 'S': { const s = readString(buf, at); args.push(s.value); at = s.next; break; }
|
||||
case 'b': { const n = buf.readInt32BE(at); args.push(buf.subarray(at + 4, at + 4 + n)); at = pad4(at + 4 + n); break; }
|
||||
case 'T': args.push(true); break;
|
||||
case 'F': args.push(false); break;
|
||||
case 'N': args.push(null); break;
|
||||
case 'I': args.push(Infinity); break;
|
||||
case 't': at += 8; break;
|
||||
default: throw new Error(`unsupported OSC type tag "${t}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ address: addr.value, args });
|
||||
}
|
||||
|
||||
/** Every message in a packet, bundles flattened, in order. */
|
||||
function decodePacket(buf, out = []) {
|
||||
if (buf.length >= 16 && buf.toString('ascii', 0, 8) === '#bundle\0') {
|
||||
let at = 16; // "#bundle\0" + 8-byte timetag
|
||||
while (at + 4 <= buf.length) {
|
||||
const n = buf.readInt32BE(at);
|
||||
at += 4;
|
||||
if (n <= 0 || at + n > buf.length) break;
|
||||
decodePacket(buf.subarray(at, at + n), out);
|
||||
at += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
decodeMessage(buf, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { encodeMessage, decodePacket };
|
||||
@@ -0,0 +1,719 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AbleSet — server part.
|
||||
*
|
||||
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
|
||||
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
|
||||
* that changes to our socket, `/getValues` fills in everything that is
|
||||
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
|
||||
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
|
||||
* network dropped) we keep asking again every few seconds until it answers,
|
||||
* because a restarted AbleSet has forgotten its subscribers.
|
||||
*
|
||||
* The raw values become one view-model, streamed to every tile:
|
||||
*
|
||||
* GET /state → { state } (instant paint)
|
||||
* GET /stream → SSE: `state` on any change, `pos` while playing
|
||||
* GET /timers(/stream) → song and set countdowns for the Timers module
|
||||
*
|
||||
* `pos` carries only what moves every frame (playhead, time left); the
|
||||
* tiles extrapolate between packets from the tempo, so 5 packets a second
|
||||
* draws a smooth playhead.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const dns = require('dns');
|
||||
const { encodeMessage, decodePacket } = require('./osc');
|
||||
|
||||
const HEARTBEAT_MS = 15000; // SSE keepalive comment
|
||||
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
|
||||
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
|
||||
const POS_MS = 200; // playhead packets to tiles while playing
|
||||
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
|
||||
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
|
||||
|
||||
/** Things AbleSet can send that no tile draws — kept off the wire. */
|
||||
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
|
||||
|
||||
let current = null;
|
||||
|
||||
function emptyState() {
|
||||
return {
|
||||
reachable: false,
|
||||
configured: false,
|
||||
lastError: '',
|
||||
live: false,
|
||||
loading: false,
|
||||
playing: false,
|
||||
paused: false,
|
||||
recording: false,
|
||||
countingIn: false,
|
||||
tempo: null,
|
||||
timeSig: null,
|
||||
setlist: { name: '', songs: [] },
|
||||
song: null,
|
||||
section: null,
|
||||
nextSection: null,
|
||||
sections: [],
|
||||
nextSong: null,
|
||||
queued: null,
|
||||
loop: { enabled: false, start: null, end: null, inSection: false },
|
||||
audio: [],
|
||||
version: '',
|
||||
pos: emptyPos(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPos() {
|
||||
return { beats: null, remainingSong: null, remainingSet: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
|
||||
const host = String(ep.host || '').trim();
|
||||
const port = Number(ep.port) || 39051;
|
||||
const configured = Boolean(host);
|
||||
|
||||
const values = new Map(); // OSC address → args, as AbleSet last said
|
||||
const streams = new Set(); // tile SSE clients
|
||||
const timerStreams = new Set(); // Timers-module SSE clients
|
||||
let lastHeard = 0;
|
||||
let lastAsked = 0;
|
||||
let resolved = '';
|
||||
let lastError = '';
|
||||
let sock = null;
|
||||
let stopped = false;
|
||||
let coalesce = null;
|
||||
let lastFrame = '';
|
||||
let lastTimersFrame = '';
|
||||
|
||||
// Playhead and countdowns, each as "value at a moment" so they can be
|
||||
// extrapolated to any other moment. AbleSet's time-left values are whole
|
||||
// seconds; restamping on every one of them makes a countdown stutter, so
|
||||
// a model is only moved when AbleSet disagrees with it by over a second.
|
||||
const beatsModel = { value: null, at: 0 };
|
||||
const songModel = { value: null, at: 0 };
|
||||
const setModel = { value: null, at: 0 };
|
||||
|
||||
const num = (a, i = 0) => {
|
||||
const v = values.get(a);
|
||||
const n = v ? Number(v[i]) : NaN;
|
||||
return v && v[i] !== null && Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const str = (a, i = 0) => {
|
||||
const v = values.get(a);
|
||||
return v && v[i] != null ? String(v[i]) : '';
|
||||
};
|
||||
const arr = (a) => values.get(a) || [];
|
||||
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
|
||||
|
||||
const isPlaying = () => flag('/global/isPlaying');
|
||||
const tempo = () => num('/global/tempo');
|
||||
|
||||
function extrapolate(model, sign) {
|
||||
if (model.value == null) return null;
|
||||
if (!isPlaying()) return model.value;
|
||||
return model.value + sign * (Date.now() - model.at) / 1000;
|
||||
}
|
||||
|
||||
function beatsNow() {
|
||||
if (beatsModel.value == null) return null;
|
||||
const t = tempo();
|
||||
if (!isPlaying() || !t) return beatsModel.value;
|
||||
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
|
||||
}
|
||||
|
||||
function nudge(model, reported) {
|
||||
if (reported == null) { model.value = null; return; }
|
||||
const predicted = extrapolate(model, -1);
|
||||
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
|
||||
model.value = reported;
|
||||
model.at = Date.now();
|
||||
} else {
|
||||
// Keep the running model; re-base it so the stamp stays recent.
|
||||
model.value = predicted;
|
||||
model.at = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function colorOf(a) {
|
||||
const v = values.get(a);
|
||||
if (!v) return null;
|
||||
const name = v[0] == null ? '' : String(v[0]);
|
||||
const hex = v[1] == null ? '' : String(v[1]);
|
||||
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
|
||||
}
|
||||
|
||||
function part(prefix) {
|
||||
const name = str(`${prefix}Name`);
|
||||
if (!name && num(`${prefix}Index`) == null) return null;
|
||||
const index = num(`${prefix}Index`);
|
||||
if (index != null && index < 0) return null;
|
||||
return {
|
||||
name,
|
||||
index,
|
||||
start: num(`${prefix}Start`),
|
||||
end: num(`${prefix}End`),
|
||||
duration: num(`${prefix}Duration`),
|
||||
color: colorOf(`${prefix}Color`),
|
||||
};
|
||||
}
|
||||
|
||||
function audioInterfaces() {
|
||||
const out = [];
|
||||
const names = arr('/audioInterfaces/names');
|
||||
const statuses = arr('/audioInterfaces/status');
|
||||
names.forEach((n, i) => {
|
||||
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
|
||||
});
|
||||
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
|
||||
if (!names.length && flag('/playaudio12/isConnected')) {
|
||||
const scene = num('/playaudio12/scene');
|
||||
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildState() {
|
||||
const songs = arr('/setlist/songs');
|
||||
const durations = arr('/setlist/songDurations');
|
||||
const colors = arr('/setlist/songColors');
|
||||
const sectionNames = arr('/setlist/sections');
|
||||
const sectionColors = arr('/setlist/sectionColors');
|
||||
const song = part('/setlist/activeSong');
|
||||
const qIdx = values.get('/setlist/queuedIndex') || [];
|
||||
const qName = values.get('/setlist/queuedName') || [];
|
||||
const queuedType = str('/setlist/queuedType');
|
||||
const ts = values.get('/global/timeSignature');
|
||||
return {
|
||||
reachable: isReachable(),
|
||||
configured,
|
||||
lastError,
|
||||
live: flag('/global/isConnected'),
|
||||
loading: flag('/global/isLoadingFile'),
|
||||
playing: isPlaying(),
|
||||
paused: flag('/global/isPaused'),
|
||||
recording: flag('/global/isRecording'),
|
||||
countingIn: flag('/setlist/isCountingIn'),
|
||||
tempo: tempo(),
|
||||
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
|
||||
setlist: {
|
||||
name: str('/setlist/name'),
|
||||
songs: songs.map((name, i) => ({
|
||||
name: String(name ?? ''),
|
||||
duration: durations[i] == null ? null : Number(durations[i]),
|
||||
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
|
||||
})),
|
||||
},
|
||||
song,
|
||||
section: part('/setlist/activeSection'),
|
||||
nextSection: part('/setlist/nextSection'),
|
||||
sections: sectionNames.map((name, i) => ({
|
||||
name: String(name ?? ''),
|
||||
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
|
||||
})),
|
||||
sectionLoop: {
|
||||
mode: str('/setlist/activeSectionLoopMode'),
|
||||
count: num('/setlist/activeSectionLoopCount'),
|
||||
},
|
||||
nextSong: part('/setlist/nextSong'),
|
||||
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
|
||||
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
|
||||
: null,
|
||||
loop: {
|
||||
enabled: flag('/setlist/loopEnabled'),
|
||||
start: num('/setlist/loopStart'),
|
||||
end: num('/setlist/loopEnd'),
|
||||
inSection: flag('/setlist/isInActiveSectionLoop'),
|
||||
},
|
||||
audio: audioInterfaces(),
|
||||
version: str('/version/app'),
|
||||
pos: buildPos(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
|
||||
* connected; without them the song's length and the playhead still say
|
||||
* how far there is to go, and the set is the rest of the setlist on top.
|
||||
*/
|
||||
function songLeftNow() {
|
||||
const reported = extrapolate(songModel, -1);
|
||||
if (reported != null) return reported;
|
||||
const dur = num('/setlist/activeSongDuration');
|
||||
const start = num('/setlist/activeSongStart');
|
||||
const end = num('/setlist/activeSongEnd');
|
||||
const b = beatsNow();
|
||||
if (dur == null || start == null || end == null || end <= start || b == null) return null;
|
||||
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
|
||||
}
|
||||
|
||||
function setLeftNow() {
|
||||
const reported = extrapolate(setModel, -1);
|
||||
if (reported != null) return reported;
|
||||
const song = songLeftNow();
|
||||
const idx = num('/setlist/activeSongIndex');
|
||||
if (song == null || idx == null) return null;
|
||||
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
|
||||
}
|
||||
|
||||
function buildPos() {
|
||||
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
|
||||
return {
|
||||
beats: round(beatsNow(), 1000),
|
||||
remainingSong: round(songLeftNow(), 100),
|
||||
remainingSet: round(setLeftNow(), 100),
|
||||
};
|
||||
}
|
||||
|
||||
function isReachable() {
|
||||
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
|
||||
}
|
||||
|
||||
/* ── fan-out ─────────────────────────────────────────────────── */
|
||||
|
||||
function send(set, payload) {
|
||||
for (const res of set) {
|
||||
try { res.write(payload); } catch { set.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastState() {
|
||||
coalesce = null;
|
||||
const state = buildState();
|
||||
// Change detection ignores the moving parts, which `pos` carries.
|
||||
const { pos, ...still } = state;
|
||||
const frame = JSON.stringify(still);
|
||||
if (frame !== lastFrame) {
|
||||
lastFrame = frame;
|
||||
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
}
|
||||
broadcastTimers();
|
||||
}
|
||||
|
||||
function scheduleState() {
|
||||
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
|
||||
}
|
||||
|
||||
function broadcastTimers() {
|
||||
if (!timerStreams.size) return;
|
||||
const snap = timersSnapshot();
|
||||
const { updatedAt, ...rest } = snap;
|
||||
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
|
||||
if (key === lastTimersFrame) return;
|
||||
lastTimersFrame = key;
|
||||
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
}
|
||||
|
||||
function timersSnapshot() {
|
||||
const now = Date.now();
|
||||
const playing = isPlaying();
|
||||
const reachable = isReachable();
|
||||
const song = part('/setlist/activeSong');
|
||||
const remSong = songLeftNow();
|
||||
const remSet = setLeftNow();
|
||||
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
|
||||
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
|
||||
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
|
||||
const songs = arr('/setlist/songs');
|
||||
const idx = num('/setlist/activeSongIndex');
|
||||
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
|
||||
return {
|
||||
source: 'ableset',
|
||||
label: 'AbleSet',
|
||||
updatedAt: now,
|
||||
timers: [
|
||||
{
|
||||
id: 'song',
|
||||
label: 'Song',
|
||||
kind: 'countdown',
|
||||
state,
|
||||
startedAt: playing && songElapsed != null ? now - songElapsed : null,
|
||||
elapsedMs: songElapsed,
|
||||
remainingMs: ms(remSong),
|
||||
targetMs: ms(song?.duration),
|
||||
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
|
||||
detail: str('/setlist/activeSectionName') || null,
|
||||
},
|
||||
{
|
||||
id: 'set',
|
||||
label: 'Set',
|
||||
kind: 'countdown',
|
||||
state,
|
||||
startedAt: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: ms(remSet),
|
||||
targetMs: null,
|
||||
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
|
||||
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── OSC in ──────────────────────────────────────────────────── */
|
||||
|
||||
const FAST = new Set([
|
||||
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
|
||||
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
|
||||
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
|
||||
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
|
||||
]);
|
||||
|
||||
function onMessage(m) {
|
||||
const { address, args } = m;
|
||||
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
|
||||
const v = Number(args[0]);
|
||||
if (Number.isFinite(v)) {
|
||||
// A whole-beat report while fine ones flow would drag the playhead back.
|
||||
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
|
||||
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
|
||||
beatsModel.value = v;
|
||||
beatsModel.at = Date.now();
|
||||
}
|
||||
} else if (address === '/setlist/remainingTimeInSong') {
|
||||
nudge(songModel, args[0] == null ? null : Number(args[0]));
|
||||
} else if (address === '/setlist/remainingTimeInSet') {
|
||||
nudge(setModel, args[0] == null ? null : Number(args[0]));
|
||||
} else if (address === '/global/isPlaying') {
|
||||
// Freeze the models at the moment the transport changed.
|
||||
const b = beatsNow();
|
||||
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
|
||||
for (const mdl of [songModel, setModel]) {
|
||||
const v = extrapolate(mdl, -1);
|
||||
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
|
||||
}
|
||||
}
|
||||
values.set(address, args);
|
||||
if (!FAST.has(address)) scheduleState();
|
||||
}
|
||||
|
||||
/* ── connection ──────────────────────────────────────────────── */
|
||||
|
||||
function ask() {
|
||||
if (!sock || stopped) return;
|
||||
lastAsked = Date.now();
|
||||
dns.lookup(host, { family: 4 }, (err, addr) => {
|
||||
if (stopped || !sock) return;
|
||||
if (err) {
|
||||
lastError = `can't find ${host} (${err.code || err.message})`;
|
||||
scheduleState();
|
||||
return;
|
||||
}
|
||||
resolved = addr;
|
||||
const myPort = sock.address().port;
|
||||
const out = (address, args) => {
|
||||
try {
|
||||
sock.send(encodeMessage(address, args), port, addr, (e) => {
|
||||
if (e) lastError = e.message;
|
||||
});
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
}
|
||||
};
|
||||
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
|
||||
out('/getValues', ['auto', myPort]);
|
||||
});
|
||||
}
|
||||
|
||||
const watchdog = setInterval(() => {
|
||||
if (!configured || stopped) return;
|
||||
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
|
||||
if (!isReachable()) {
|
||||
if (wasReachable) {
|
||||
log(`no heartbeat from AbleSet at ${host} — asking again`);
|
||||
lastError = 'AbleSet stopped answering';
|
||||
scheduleState();
|
||||
}
|
||||
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
|
||||
}
|
||||
}, 1000);
|
||||
watchdog.unref?.();
|
||||
|
||||
// While playing, the playhead goes out five times a second. While
|
||||
// stopped it only goes out when it moved — a jump, a scrub.
|
||||
let lastPos = '';
|
||||
const posTimer = setInterval(() => {
|
||||
if (!streams.size || !isReachable()) return;
|
||||
const frame = JSON.stringify(buildPos());
|
||||
if (!isPlaying() && frame === lastPos) return;
|
||||
lastPos = frame;
|
||||
send(streams, `event: pos\ndata: ${frame}\n\n`);
|
||||
}, POS_MS);
|
||||
posTimer.unref?.();
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
send(streams, ': ping\n\n');
|
||||
send(timerStreams, ': ping\n\n');
|
||||
// The Timers module derives running values itself; a fresh anchor now
|
||||
// and then keeps a long song honest.
|
||||
if (timerStreams.size) {
|
||||
lastTimersFrame = '';
|
||||
broadcastTimers();
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
keepalive.unref?.();
|
||||
|
||||
if (configured) {
|
||||
sock = dgram.createSocket('udp4');
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
|
||||
let msgs;
|
||||
try {
|
||||
msgs = decodePacket(buf);
|
||||
} catch (e) {
|
||||
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
const wasReachable = isReachable();
|
||||
lastHeard = Date.now();
|
||||
if (!wasReachable) {
|
||||
lastError = '';
|
||||
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
|
||||
scheduleState();
|
||||
}
|
||||
for (const m of msgs) onMessage(m);
|
||||
});
|
||||
sock.on('error', (e) => {
|
||||
lastError = e.message;
|
||||
log(`OSC socket error: ${e.message}`);
|
||||
});
|
||||
sock.bind(0, () => {
|
||||
log(`following AbleSet at ${host}:${port}`);
|
||||
ask();
|
||||
});
|
||||
} else {
|
||||
log('no AbleSet host configured — set one in /admin');
|
||||
}
|
||||
|
||||
current = {
|
||||
streams,
|
||||
timerStreams,
|
||||
getState: buildState,
|
||||
getTimers: timersSnapshot,
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(watchdog);
|
||||
clearInterval(posTimer);
|
||||
clearInterval(keepalive);
|
||||
clearTimeout(coalesce);
|
||||
if (sock) {
|
||||
// Be polite: tell AbleSet to stop sending to a port nobody reads.
|
||||
const s = sock;
|
||||
sock = null;
|
||||
try {
|
||||
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
|
||||
else s.close();
|
||||
} catch {
|
||||
try { s.close(); } catch { /* closed */ }
|
||||
}
|
||||
}
|
||||
for (const set of [streams, timerStreams]) {
|
||||
for (const res of set) { try { res.end(); } catch { /* gone */ } }
|
||||
set.clear();
|
||||
}
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
|
||||
if (isReachable()) {
|
||||
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
|
||||
const set = str('/setlist/name');
|
||||
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
|
||||
}
|
||||
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
|
||||
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* One picker entry per tile kind. Each carries only the gear settings its
|
||||
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
|
||||
* Now Playing card's longer list folds into a Show group.
|
||||
*/
|
||||
tiles() {
|
||||
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
|
||||
const clock = {
|
||||
type: 'select',
|
||||
label: 'Song time',
|
||||
default: 'remaining',
|
||||
options: [
|
||||
{ value: 'remaining', label: 'Time left' },
|
||||
{ value: 'elapsed', label: 'Elapsed' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
],
|
||||
};
|
||||
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
|
||||
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
|
||||
return [
|
||||
{
|
||||
id: 'now',
|
||||
name: 'Now Playing',
|
||||
description: 'The song, its tempo, time left, section, progress and what comes next',
|
||||
defaultSize: { w: 8, h: 4 },
|
||||
minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
clock,
|
||||
songColors: sw('Song colours', { default: false }),
|
||||
showTempo: sw('Tempo', { group: 'Show' }),
|
||||
showMeter: sw('Time signature', { group: 'Show' }),
|
||||
showDuration: sw('Duration', { group: 'Show' }),
|
||||
showClock: sw('Song time', { group: 'Show' }),
|
||||
showSection: sw('Section', { group: 'Show' }),
|
||||
showProgress: sw('Progress bar', { group: 'Show' }),
|
||||
showNext: sw('Next song', { group: 'Show' }),
|
||||
showLeft: sw('Songs left', { group: 'Show' }),
|
||||
warnAt,
|
||||
alertAt,
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
|
||||
},
|
||||
{
|
||||
id: 'sections',
|
||||
name: 'Song Sections',
|
||||
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
|
||||
defaultSize: { w: 8, h: 2 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
instanceSchema: {
|
||||
showProgress: sw('Section progress'),
|
||||
showSong: sw('Song name', { default: false }),
|
||||
sectionColors: sw('Section colours', { default: false }),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'setlist',
|
||||
name: 'Setlist',
|
||||
description: 'Every song in the set, played, playing and to come',
|
||||
defaultSize: { w: 3, h: 6 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
instanceSchema: {
|
||||
showDurations: sw('Durations'),
|
||||
showPlayed: sw('Played songs'),
|
||||
showTotal: sw('Time left'),
|
||||
songColors: sw('Song colours', { default: false }),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'transport',
|
||||
name: 'Transport',
|
||||
description: 'Playing, stopped, counting in or looping, in colour',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
showPosition: sw('Bar and beat'),
|
||||
showLoop: sw('Loop'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tempo',
|
||||
name: 'Tempo',
|
||||
description: 'BPM and time signature, with a light on every beat',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
showMeter: sw('Time signature'),
|
||||
showBeats: sw('Beat lights'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'song-time',
|
||||
name: 'Song Time',
|
||||
description: 'Time left in the song, turning amber then red near the end',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
clock,
|
||||
showSong: sw('Song name'),
|
||||
showProgress: sw('Progress bar'),
|
||||
warnAt,
|
||||
alertAt,
|
||||
},
|
||||
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
|
||||
},
|
||||
{
|
||||
id: 'set-time',
|
||||
name: 'Set Time',
|
||||
description: 'Time left in the whole set and how many songs remain',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
showLeft: sw('Songs left'),
|
||||
showEnd: sw('End time'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'section',
|
||||
name: 'Section',
|
||||
description: 'The section playing now, what is next, and the count to the change',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
showNext: sw('Next section'),
|
||||
showCount: sw('Countdown'),
|
||||
sectionColors: sw('Section colours', { default: false }),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'next-song',
|
||||
name: 'Next Song',
|
||||
description: 'The song after this one, and anything queued',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 1, h: 1 },
|
||||
instanceSchema: {
|
||||
showDuration: sw('Duration'),
|
||||
showQueued: sw('Queued'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
name: 'AbleSet Status',
|
||||
description: 'AbleSet, Ableton Live and audio interface health at a glance',
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
instanceSchema: {
|
||||
showAudio: sw('Audio interfaces'),
|
||||
showSetlist: sw('Setlist name'),
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
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));
|
||||
};
|
||||
const openSse = (req, res, set, first) => {
|
||||
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');
|
||||
res.write(first);
|
||||
if (!set) return void res.end();
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
set.add(res);
|
||||
req.on('close', () => set.delete(res));
|
||||
};
|
||||
return {
|
||||
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
||||
'GET /stream': (req, res) => {
|
||||
const state = current ? current.getState() : emptyState();
|
||||
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
},
|
||||
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
|
||||
'GET /timers/stream': (req, res) => {
|
||||
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
|
||||
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
/* AbleSet tiles. Every size hangs off the tile itself (container units), so
|
||||
a tile reads the same at 2×1 on a laptop as at 12×6 on a wall. Colours are
|
||||
the shell's variables; a tone (ok / warn / alert / muted) picks among them,
|
||||
and AbleSet's own song or section colour arrives as --song when a tile is
|
||||
set to use it. */
|
||||
|
||||
[data-module="ableset"] .as-root {
|
||||
--tone: var(--text);
|
||||
--live: var(--accent);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
container-type: size;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="ableset"] .as-root.has-song-color { --live: var(--song); }
|
||||
[data-module="ableset"] .as-root[data-tone="ok"] { --tone: var(--accent); }
|
||||
[data-module="ableset"] .as-root[data-tone="warn"] { --tone: var(--warn); }
|
||||
[data-module="ableset"] .as-root[data-tone="alert"] { --tone: var(--danger); }
|
||||
[data-module="ableset"] .as-root[data-tone="muted"] { --tone: var(--muted); }
|
||||
|
||||
[data-module="ableset"] .as-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: clamp(4px, 4cqmin, 28px);
|
||||
gap: clamp(2px, 2.5cqh, 18px);
|
||||
min-width: 0;
|
||||
}
|
||||
[data-module="ableset"] [hidden] { display: none !important; }
|
||||
|
||||
[data-module="ableset"] .as-offline {
|
||||
position: absolute;
|
||||
top: clamp(3px, 3cqmin, 14px);
|
||||
right: clamp(4px, 4cqmin, 18px);
|
||||
z-index: 2;
|
||||
font-size: clamp(9px, 6cqmin, 15px);
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
[data-module="ableset"] .as-root.is-offline .as-body { opacity: 0.35; filter: grayscale(0.7); }
|
||||
|
||||
[data-module="ableset"] [data-fit] {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── the song bar: played sections · this section · the playhead ── */
|
||||
|
||||
[data-module="ableset"] .as-bar {
|
||||
--done: 0%;
|
||||
--sect-l: 0%;
|
||||
--sect-w: 0%;
|
||||
--played: 0%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: clamp(4px, 5.5cqh, 26px);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--live) 14%, var(--panel));
|
||||
}
|
||||
[data-module="ableset"] .as-bar-done {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--done);
|
||||
background: color-mix(in srgb, var(--live) 32%, var(--panel));
|
||||
}
|
||||
[data-module="ableset"] .as-bar-sect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--sect-l);
|
||||
width: var(--sect-w);
|
||||
background: color-mix(in srgb, var(--live) 55%, var(--panel));
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="ableset"] .as-bar-played {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--played);
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--live) 70%, var(--panel)) 0%,
|
||||
color-mix(in srgb, var(--live) 70%, var(--panel)) calc(100% - 3.5cqw),
|
||||
color-mix(in srgb, var(--live) 70%, var(--text)) 100%);
|
||||
}
|
||||
|
||||
/* ── NOW PLAYING ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="ableset"] .as-now .as-body {
|
||||
align-items: center;
|
||||
padding-inline: clamp(6px, 7cqw, 90px);
|
||||
gap: clamp(3px, 4cqh, 30px);
|
||||
}
|
||||
[data-module="ableset"] .as-now .as-title-box,
|
||||
[data-module="ableset"] .as-now .as-next-box { width: 100%; display: flex; justify-content: center; min-width: 0; }
|
||||
[data-module="ableset"] .as-now .as-title {
|
||||
font-size: clamp(14px, 20cqh, 170px);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
[data-module="ableset"] .as-now.is-playing .as-title { color: var(--live); }
|
||||
[data-module="ableset"] .as-now .as-bar { width: 100%; }
|
||||
|
||||
[data-module="ableset"] .as-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: clamp(3px, 1.8cqh, 16px);
|
||||
max-width: 100%;
|
||||
}
|
||||
[data-module="ableset"] .as-badge {
|
||||
font-size: clamp(9px, 7cqh, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0.28em 0.5em;
|
||||
border-radius: 0.22em;
|
||||
background: color-mix(in srgb, var(--text) 12%, var(--panel));
|
||||
color: color-mix(in srgb, var(--text) 80%, var(--accent));
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="ableset"] .as-badge.b-section {
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
background: color-mix(in srgb, var(--text) 7%, var(--panel));
|
||||
}
|
||||
[data-module="ableset"] .as-badge.b-clock[data-tone="warn"] { color: var(--accent-text); background: var(--warn); }
|
||||
[data-module="ableset"] .as-badge.b-clock[data-tone="alert"] { color: var(--accent-text); background: var(--danger); }
|
||||
|
||||
[data-module="ableset"] .as-next {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: clamp(1px, 0.6cqh, 6px);
|
||||
}
|
||||
[data-module="ableset"] .as-next-label {
|
||||
font-size: clamp(8px, 5cqh, 30px);
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, var(--live) 55%, var(--muted));
|
||||
}
|
||||
[data-module="ableset"] .as-next.is-queued .as-next-label { color: var(--warn); }
|
||||
[data-module="ableset"] .as-next-title {
|
||||
font-size: clamp(11px, 12cqh, 90px);
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
color: color-mix(in srgb, var(--live) 45%, var(--muted));
|
||||
}
|
||||
[data-module="ableset"] .as-left {
|
||||
font-size: clamp(8px, 6cqh, 36px);
|
||||
color: color-mix(in srgb, var(--live) 40%, var(--muted));
|
||||
}
|
||||
|
||||
/* ── SONG SECTIONS ──────────────────────────────────────────────── */
|
||||
|
||||
[data-module="ableset"] .as-sections .as-body { padding: clamp(3px, 3cqmin, 16px) 0; gap: clamp(2px, 2cqh, 10px); }
|
||||
[data-module="ableset"] .as-sections .as-song-name {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 0 clamp(4px, 3cqw, 20px);
|
||||
font-size: clamp(9px, 13cqh, 28px);
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="ableset"] .as-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
|
||||
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
|
||||
}
|
||||
[data-module="ableset"] .as-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(4px, 2cqw, 18px);
|
||||
transition: transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
[data-module="ableset"] .as-track.no-anim,
|
||||
[data-module="ableset"] .as-track.no-anim .as-pill { transition: none !important; }
|
||||
|
||||
[data-module="ableset"] .as-pill {
|
||||
--p: 0%;
|
||||
--sect: var(--accent);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: min(78%, 22cqw);
|
||||
padding: 0 0.8em;
|
||||
border-radius: 0.35em;
|
||||
font-size: clamp(9px, 26cqh, 64px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--text) 8%, var(--panel));
|
||||
border: 2px solid transparent;
|
||||
transition: background 0.4s, color 0.4s, border-color 0.4s, opacity 0.5s, transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
|
||||
}
|
||||
[data-module="ableset"] .as-pill.has-color { background: color-mix(in srgb, var(--sect) 22%, var(--panel)); }
|
||||
[data-module="ableset"] .as-pill.is-past { opacity: 0.45; }
|
||||
[data-module="ableset"] .as-pill.is-next { border-color: color-mix(in srgb, var(--sect) 60%, transparent); color: var(--text); }
|
||||
[data-module="ableset"] .as-pill.is-current {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--sect) 55%, var(--panel));
|
||||
}
|
||||
[data-module="ableset"] .as-pill-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: var(--sect);
|
||||
}
|
||||
[data-module="ableset"] .as-pill.is-current .as-pill-fill { width: var(--p); }
|
||||
[data-module="ableset"] .as-pill-text { position: relative; }
|
||||
[data-module="ableset"] .as-pill.is-empty { color: var(--muted); }
|
||||
|
||||
/* the lottery wheel: faces on a drum turning towards the viewer */
|
||||
[data-module="ableset"] .is-wheel .as-viewport {
|
||||
perspective: 900px;
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
|
||||
mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
|
||||
}
|
||||
[data-module="ableset"] .is-wheel .as-track {
|
||||
--face-h: 30px;
|
||||
inset: 50% 0 auto 0;
|
||||
display: block;
|
||||
height: 0;
|
||||
transform-style: preserve-3d;
|
||||
transition: none;
|
||||
}
|
||||
[data-module="ableset"] .is-wheel .as-pill {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
translate: -50% 0;
|
||||
height: var(--face-h);
|
||||
justify-content: center;
|
||||
min-width: 55%;
|
||||
max-width: 94%;
|
||||
font-size: clamp(9px, calc(var(--face-h) * 0.55), 72px);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* ── SETLIST ─────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="ableset"] .as-setlist .as-body { justify-content: flex-start; gap: 6px; padding: 8px; }
|
||||
[data-module="ableset"] .as-set-head {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="ableset"] .as-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; scrollbar-width: thin; }
|
||||
[data-module="ableset"] .as-row {
|
||||
--p: 0%;
|
||||
--song: var(--accent);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.35em 0.55em;
|
||||
border-radius: 6px;
|
||||
font-size: clamp(11px, 5.5cqw, 19px);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
[data-module="ableset"] .as-row-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
[data-module="ableset"] .as-row.is-current .as-row-fill { width: var(--p); }
|
||||
[data-module="ableset"] .as-row-name { position: relative; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
[data-module="ableset"] .as-row-dur { position: relative; color: var(--muted); font-size: 0.85em; }
|
||||
[data-module="ableset"] .as-swatch { position: relative; width: 0.55em; height: 0.55em; border-radius: 50%; background: var(--song); flex-shrink: 0; }
|
||||
[data-module="ableset"] .as-row.is-past { opacity: 0.4; }
|
||||
[data-module="ableset"] .as-row.is-current { font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
[data-module="ableset"] .as-row.is-next { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--text) 40%, transparent); }
|
||||
[data-module="ableset"] .as-row.is-queued { box-shadow: inset 3px 0 0 var(--warn); color: var(--warn); }
|
||||
[data-module="ableset"] .as-set-foot { flex-shrink: 0; font-size: 0.85rem; color: var(--muted); text-align: right; }
|
||||
[data-module="ableset"] .as-empty { color: var(--muted); padding: 1em; text-align: center; }
|
||||
|
||||
/* ── single readouts: transport, tempo, times, section, next ────── */
|
||||
|
||||
[data-module="ableset"] .as-big-box { display: flex; justify-content: center; min-width: 0; width: 100%; }
|
||||
[data-module="ableset"] .as-big {
|
||||
font-size: clamp(14px, 38cqh, 220px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--tone);
|
||||
text-align: center;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
[data-module="ableset"] .as-big small { font-size: 0.35em; font-weight: 700; color: var(--muted); }
|
||||
[data-module="ableset"] .as-caption {
|
||||
text-align: center;
|
||||
font-size: clamp(8px, 12cqh, 30px);
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
[data-module="ableset"] .as-sub {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25em 1em;
|
||||
font-size: clamp(8px, 12cqh, 34px);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="ableset"] .as-sub:empty { display: none; }
|
||||
|
||||
/* A tone washes the tile's background, so a wall of them reads at a glance. */
|
||||
[data-module="ableset"] .as-transport,
|
||||
[data-module="ableset"] .as-song-time,
|
||||
[data-module="ableset"] .as-section,
|
||||
[data-module="ableset"] .as-status,
|
||||
[data-module="ableset"] .as-next-song {
|
||||
background: color-mix(in srgb, var(--tone) 9%, transparent);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
[data-module="ableset"] .as-root[data-tone="muted"],
|
||||
[data-module="ableset"] .as-root[data-tone=""] { background: none; }
|
||||
|
||||
[data-module="ableset"] .as-transport .as-big { text-transform: uppercase; font-size: clamp(12px, 34cqh, 180px); }
|
||||
[data-module="ableset"] .as-transport .as-loop { color: var(--warn); font-weight: 700; }
|
||||
|
||||
[data-module="ableset"] .as-tempo .as-big { color: var(--text); }
|
||||
[data-module="ableset"] .as-beats { display: flex; justify-content: center; gap: clamp(3px, 3cqw, 14px); flex-shrink: 0; }
|
||||
[data-module="ableset"] .as-beats i {
|
||||
width: clamp(6px, 11cqh, 30px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text) 14%, var(--panel));
|
||||
transition: background 0.08s;
|
||||
}
|
||||
[data-module="ableset"] .as-beats i.is-lit { background: var(--text); }
|
||||
[data-module="ableset"] .as-beats i.is-down.is-lit { background: var(--accent); }
|
||||
|
||||
[data-module="ableset"] .as-song-time .as-bar { height: clamp(3px, 5cqh, 14px); }
|
||||
|
||||
[data-module="ableset"] .as-section .as-big { text-transform: uppercase; }
|
||||
[data-module="ableset"] .as-section.has-song-color .as-big { color: var(--song); }
|
||||
[data-module="ableset"] .as-section .as-count { color: var(--tone); font-weight: 700; }
|
||||
[data-module="ableset"] .as-sect-bar {
|
||||
--p: 0%;
|
||||
height: clamp(3px, 4cqh, 12px);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 12%, var(--panel));
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
[data-module="ableset"] .as-sect-bar > div { height: 100%; width: var(--p); background: var(--tone); }
|
||||
|
||||
[data-module="ableset"] .as-next-song .as-big { color: var(--text); font-size: clamp(12px, 30cqh, 150px); }
|
||||
[data-module="ableset"] .as-queued {
|
||||
align-self: center;
|
||||
font-size: clamp(8px, 11cqh, 28px);
|
||||
font-weight: 700;
|
||||
padding: 0.2em 0.6em;
|
||||
border-radius: 0.3em;
|
||||
color: var(--accent-text);
|
||||
background: var(--warn);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── STATUS ──────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="ableset"] .as-status .as-body { justify-content: flex-start; padding: 8px 10px; }
|
||||
[data-module="ableset"] .as-rows { display: flex; flex-direction: column; gap: 4px; }
|
||||
[data-module="ableset"] .as-srow {
|
||||
--dot: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: clamp(11px, 4.5cqw, 18px);
|
||||
padding: 0.3em 0.5em;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--dot) 10%, transparent);
|
||||
}
|
||||
[data-module="ableset"] .as-srow[data-tone="ok"] { --dot: var(--accent); }
|
||||
[data-module="ableset"] .as-srow[data-tone="warn"] { --dot: var(--warn); }
|
||||
[data-module="ableset"] .as-srow[data-tone="alert"] { --dot: var(--danger); }
|
||||
[data-module="ableset"] .as-srow[data-tone=""] { --dot: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||
[data-module="ableset"] .as-srow i { width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--dot); flex-shrink: 0; }
|
||||
[data-module="ableset"] .as-sk { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
[data-module="ableset"] .as-sv { font-weight: 700; color: var(--dot); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
|
||||
[data-module="ableset"] .as-srow[data-tone=""] .as-sv { color: var(--text); }
|
||||
@@ -0,0 +1,299 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
|
||||
const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let state = null;
|
||||
let editing = false;
|
||||
let choirBtn = null;
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
const ribbon = Math.random() < 0.55;
|
||||
return {
|
||||
x: Math.random() * w, y: seedY != null ? seedY : Math.random() * h,
|
||||
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, h: ribbon ? 9 + Math.random() * 8 : 0, ribbon,
|
||||
color: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
|
||||
rot: Math.random() * Math.PI * 2, vr: -0.14 + Math.random() * 0.28,
|
||||
sway: 0.6 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2,
|
||||
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
|
||||
};
|
||||
}
|
||||
function seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
|
||||
function step(c) {
|
||||
const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (c._w !== w || c._h !== h) { c.width = w * dpr; c.height = h * dpr; c._ctx = c.getContext('2d'); c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); c._w = w; c._h = h; seed(c, w, h); }
|
||||
const ctx = c._ctx; ctx.clearRect(0, 0, w, h);
|
||||
for (const p of c._parts) {
|
||||
p.phase += 0.03; p.y += p.vy; p.x += Math.sin(p.phase) * p.sway; p.rot += p.vr;
|
||||
if (p.y > h + 16) Object.assign(p, makeParticle(w, h, c._intensity, -16));
|
||||
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color;
|
||||
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
|
||||
function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
|
||||
// Per-tile Columns wins; 0 falls back to the admin default.
|
||||
function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
|
||||
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||
function photoNode(person, cls) {
|
||||
if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
|
||||
return el('div', 'bl-initials', initials(person && person.name));
|
||||
}
|
||||
async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
|
||||
|
||||
/* ---------- display board ---------- */
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; }
|
||||
|
||||
const board = el('div', 'bl-board');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
|
||||
const s = moduleApi.instanceSettings;
|
||||
const birthdays = s.birthdays !== false;
|
||||
const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
|
||||
const testIndex = birthdays ? String(s.birthdayTest || '') : '';
|
||||
const cols = resolveCols();
|
||||
const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
|
||||
board.style.setProperty('--bl-cols', cols);
|
||||
board.style.setProperty('--bl-rows', rows);
|
||||
|
||||
for (const slot of state.slots || []) {
|
||||
const cell = el('div', 'bl-slot');
|
||||
const person = slot.person;
|
||||
if (person) {
|
||||
const tile = el('div', 'bl-tile');
|
||||
const photo = el('div', 'bl-photo');
|
||||
photo.appendChild(photoNode(person));
|
||||
const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
|
||||
if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
|
||||
if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
|
||||
tile.appendChild(photo);
|
||||
if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
|
||||
cell.appendChild(tile);
|
||||
} else {
|
||||
const empty = el('div', 'bl-tile bl-empty');
|
||||
empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
|
||||
cell.appendChild(empty);
|
||||
}
|
||||
board.appendChild(cell);
|
||||
}
|
||||
root.appendChild(board);
|
||||
applyParallax(board, s.parallax !== false);
|
||||
reportStatus();
|
||||
}
|
||||
|
||||
/* ---------- parallax: each photo drifts on its own, automatically ---------- */
|
||||
let parallaxRaf = null;
|
||||
let parallaxImgs = [];
|
||||
function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
|
||||
function applyParallax(board, on) {
|
||||
stopParallax();
|
||||
const imgs = [...board.querySelectorAll('.bl-img')];
|
||||
if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
|
||||
// Give each image its own gentle, endless drift — different amplitude, speed and phase,
|
||||
// so the wall never looks frozen and no two photos move together. No mouse needed.
|
||||
parallaxImgs = imgs.map((img) => ({
|
||||
img,
|
||||
ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
|
||||
// Very slow: a full drift cycle takes roughly a minute, so the motion only
|
||||
// registers if you actually watch for it.
|
||||
sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
|
||||
px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
const tick = () => {
|
||||
const t = performance.now() / 1000;
|
||||
for (const p of parallaxImgs) {
|
||||
if (!p.img.isConnected) continue;
|
||||
p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
|
||||
}
|
||||
parallaxRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
parallaxRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function reportStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
|
||||
return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
boardEl.style.setProperty('--bl-cols', resolveCols());
|
||||
for (const slot of state?.slots || []) {
|
||||
const cell = el('div', 'bl-edit-slot');
|
||||
cell.dataset.index = String(slot.index);
|
||||
const photo = el('div', 'bl-edit-photo');
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
nameRow.appendChild(nameBtn);
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(boardEl);
|
||||
|
||||
// Library
|
||||
const lib = el('div', 'bl-library');
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
|
||||
item.appendChild(nm);
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
body.appendChild(lib);
|
||||
wrap.appendChild(body);
|
||||
|
||||
root.innerHTML = '';
|
||||
root.appendChild(wrap);
|
||||
}
|
||||
|
||||
function assign(slotIndex, personId) {
|
||||
selectedLibId = '';
|
||||
api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
|
||||
}
|
||||
function renameSlot(slotIndex, currentName) {
|
||||
const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
|
||||
if (name === null) return;
|
||||
api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||
}
|
||||
function renameLibrary(id, currentName) {
|
||||
const name = prompt('Rename this photo:', currentName || '');
|
||||
if (name === null) return;
|
||||
api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/* ---------- lifecycle ---------- */
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
const SYNC_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
|
||||
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
||||
moduleApi.header.addButton({
|
||||
icon: SYNC_ICON,
|
||||
title: 'Sync',
|
||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||
});
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
const bumpCols = (d) => {
|
||||
const max = Math.max(1, state && state.slots ? state.slots.length : 12);
|
||||
const next = Math.max(1, Math.min(max, resolveCols() + d));
|
||||
moduleApi.saveInstanceSettings({ columns: next });
|
||||
render();
|
||||
};
|
||||
moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
|
||||
moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
|
||||
const CHOIR_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
|
||||
choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
|
||||
moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
|
||||
stream = moduleApi.sse('/stream', {
|
||||
error() { reportStatus(); },
|
||||
events: {
|
||||
state(e) {
|
||||
try { state = JSON.parse(e.data); } catch { return; }
|
||||
// While editing, don't yank a rename prompt out from under the user:
|
||||
// re-render the editor only (state carries board + library).
|
||||
render();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onResize() {},
|
||||
stop() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Slot ↔ Planning Center position matching for the Band Lineup module.
|
||||
*
|
||||
* A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
|
||||
* "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
|
||||
* team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
|
||||
* people to slots by loose token overlap — the same logic the backstage
|
||||
* Electron app used, lifted here unchanged so the board matches what the booth
|
||||
* is used to. Pure functions only: no I/O, so this file is unit-testable.
|
||||
*/
|
||||
|
||||
function normalizeTeamName(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
|
||||
function isAllowedTeamName(value, keywords) {
|
||||
const normalized = normalizeTeamName(value);
|
||||
if (!normalized) return false;
|
||||
return (keywords || []).some((kw) => {
|
||||
const k = normalizeTeamName(kw);
|
||||
return k && (normalized === k || normalized.includes(k));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLabel(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
|
||||
function normalizeToken(token) {
|
||||
const normalized = String(token || '').trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
if (/^\d+$/.test(normalized)) return '';
|
||||
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
|
||||
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
|
||||
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
|
||||
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
|
||||
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
|
||||
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
|
||||
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
|
||||
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
|
||||
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
|
||||
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
|
||||
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function labelTokens(value) {
|
||||
const normalized = normalizeLabel(value);
|
||||
if (!normalized) return [];
|
||||
const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
|
||||
if (!tokens.length && normalized) return [normalized];
|
||||
return [...new Set(tokens)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The role part of a slot label to match on: the bracketed content when present
|
||||
* ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
|
||||
* drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
|
||||
* be mistaken for a position number.
|
||||
*/
|
||||
function slotRoleText(label) {
|
||||
const brackets = String(label || '').match(/\[([^\]]*)\]/g);
|
||||
if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
|
||||
return String(label || '');
|
||||
}
|
||||
|
||||
/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
|
||||
function numbersIn(value) {
|
||||
return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
|
||||
}
|
||||
|
||||
/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
|
||||
function scoreMatch(slotLabel, positionLabel) {
|
||||
const slotNormalized = normalizeLabel(slotLabel);
|
||||
const positionNormalized = normalizeLabel(positionLabel);
|
||||
if (!slotNormalized || !positionNormalized) return 0;
|
||||
// A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
|
||||
const slotNums = numbersIn(slotLabel);
|
||||
const posNums = numbersIn(positionLabel);
|
||||
if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
|
||||
if (slotNormalized === positionNormalized) return 120;
|
||||
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
|
||||
|
||||
const slotTokens = labelTokens(slotLabel);
|
||||
const positionTokens = labelTokens(positionLabel);
|
||||
if (!slotTokens.length || !positionTokens.length) return 0;
|
||||
|
||||
const positionSet = new Set(positionTokens);
|
||||
const slotSet = new Set(slotTokens);
|
||||
const overlap = slotTokens.filter((t) => positionSet.has(t));
|
||||
if (!overlap.length) return 0;
|
||||
|
||||
const overlapCount = overlap.length;
|
||||
const slotCoverage = overlapCount / slotSet.size;
|
||||
const positionCoverage = overlapCount / positionSet.size;
|
||||
const sharedPhrase = overlap.join(' ');
|
||||
|
||||
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
|
||||
if (slotCoverage === 1) score += 18;
|
||||
if (positionCoverage === 1) score += 10;
|
||||
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign scheduled members to the ordered slot list. Greedy by best score, each
|
||||
* member used once. Returns an array aligned to `slots`, each entry the chosen
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
for (let i = 0; i < pool.length; i += 1) {
|
||||
const score = scoreMatch(role, pool[i].label);
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
const scoreOf = (name) => {
|
||||
const ln = normalizeLabel(name);
|
||||
if (!ln) return 0;
|
||||
if (ln === target) return 100; // exact full name
|
||||
const L = ln.split(' ').filter(Boolean);
|
||||
if (!L.length || !T.length) return 0;
|
||||
// Align the library name against a leading run of the person's name, token by token.
|
||||
// Every library token must equal the person's token in that position — except the LAST
|
||||
// library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
|
||||
// match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
|
||||
let ok = true; let exact = 0; let usedPrefix = false;
|
||||
for (let k = 0; k < L.length; k += 1) {
|
||||
const lt = L[k]; const tt = T[k];
|
||||
if (!tt) { ok = false; break; }
|
||||
if (lt === tt) { exact += 1; continue; }
|
||||
if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
|
||||
ok = false; break;
|
||||
}
|
||||
if (ok) {
|
||||
if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
|
||||
if (exact === 1) return 90; // first name matches exactly ("alex")
|
||||
if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
|
||||
}
|
||||
// Otherwise: a library surname that appears anywhere in the person's name.
|
||||
const last = L[L.length - 1];
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
}
|
||||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
normalizeLabel,
|
||||
normalizeToken,
|
||||
labelTokens,
|
||||
slotRoleText,
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
MIN_SCORE,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
|
||||
|
||||
const SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
|
||||
'[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
|
||||
'[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
|
||||
];
|
||||
const M = (label) => ({ label, name: label });
|
||||
const members = [
|
||||
M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
|
||||
];
|
||||
const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
|
||||
|
||||
// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
|
||||
assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
|
||||
assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
|
||||
assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
|
||||
assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
|
||||
assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
|
||||
assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
|
||||
assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
|
||||
assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
|
||||
assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
|
||||
assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
|
||||
assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
|
||||
|
||||
// A different number never matches.
|
||||
assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
|
||||
// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
|
||||
assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
|
||||
|
||||
// Team allow-list (singular stems match plurals/variants via substring).
|
||||
assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
|
||||
assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
|
||||
assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
|
||||
assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
|
||||
|
||||
console.log('matching.test.js: all assertions passed ✓');
|
||||
console.log('assignment:', JSON.stringify(out));
|
||||
|
||||
// --- bestLibraryMatch: folder-photo → PCO person by name ---
|
||||
{
|
||||
const { bestLibraryMatch } = require('./matching');
|
||||
const name = (n) => (e) => (e ? e.name : null);
|
||||
const lib = [
|
||||
{ id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
|
||||
{ id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
|
||||
];
|
||||
const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
|
||||
assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
|
||||
assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
|
||||
assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
|
||||
assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
|
||||
assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
|
||||
// surname-named photo
|
||||
assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
|
||||
// two people share a first name + only a first-name photo → ambiguous only if two matches tie;
|
||||
// here a single "Alex" photo still maps to whoever's first name is exactly Alex.
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// exact full name beats a first-name photo
|
||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 4,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 8,
|
||||
"h": 5
|
||||
},
|
||||
"configGroups": {
|
||||
"Planning Center credentials": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Reading photos and birthdays needs People access on the token. Saved secrets never reach browsers."
|
||||
},
|
||||
"Stage": {
|
||||
"help": "One mic/pack label per line, in the order they sit on stage. Scheduled people are matched into these by their Planning Center position (e.g. a line '[VOX 2] MIX 2' takes whoever is scheduled on Vox 2). Blank lines are empty spots."
|
||||
},
|
||||
"Photo library": {
|
||||
"help": "Paste a Google Drive folder share link (set the folder to 'Anyone with the link → Viewer'). Every image is imported (file name → person's name) and kept in sync — add or remove photos in Drive and the board follows. A local folder path also works. If Drive can't be read, the board falls back to Planning Center photos."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"label": "Client 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 Client ID + Secret)",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
"serviceTypeId": {
|
||||
"type": "select",
|
||||
"label": "Service type",
|
||||
"default": "",
|
||||
"optionsRoute": "/service-types"
|
||||
},
|
||||
"planId": {
|
||||
"type": "select",
|
||||
"label": "Plan",
|
||||
"default": "",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Automatic — today's plan, else the next upcoming"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/plans",
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
},
|
||||
"teams": {
|
||||
"type": "text",
|
||||
"label": "Teams to include",
|
||||
"default": "Band\nWorship\nVocal\nProduction\nCommunication",
|
||||
"help": "One team name (or keyword) per line. A person on any matching Planning Center team is eligible for the board."
|
||||
},
|
||||
"slots": {
|
||||
"type": "text",
|
||||
"label": "Stage slots",
|
||||
"default": "[LEADER, VOX 1, ACOUSTIC] MIX 1\n[VOX 2] MIX 2\n[VOX 3] MIX 3\n[VOX 4] MIX 4\n[GUITAR, LEAD] MIX 5\n[BASS] MIX 6\n[VOX 5] MIX 7\n[PIANO]\n[DRUMS]\n[KEYS 2]\n[CALL TO WORSHIP] MC2\nSTAGE MANAGER\n[MC] MC1\n[SPEAKER] HEADSET",
|
||||
"group": "Stage"
|
||||
},
|
||||
"columns": {
|
||||
"type": "number",
|
||||
"label": "Columns",
|
||||
"default": 7,
|
||||
"group": "Stage",
|
||||
"help": "Default slots per row (each screen can still adjust with the − / + buttons in its title bar)."
|
||||
},
|
||||
"photoFolder": {
|
||||
"type": "string",
|
||||
"label": "Google Drive folder link",
|
||||
"default": "",
|
||||
"group": "Photo library",
|
||||
"help": "e.g. https://drive.google.com/drive/folders/… (a local folder path also works). Leave blank to use Planning Center photos only."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Birthday": {
|
||||
"toggle": "birthdays"
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showNames": {
|
||||
"type": "boolean",
|
||||
"label": "Names",
|
||||
"default": true
|
||||
},
|
||||
"showLabels": {
|
||||
"type": "boolean",
|
||||
"label": "Mic labels",
|
||||
"default": true
|
||||
},
|
||||
"parallax": {
|
||||
"type": "boolean",
|
||||
"label": "Parallax motion",
|
||||
"default": true
|
||||
},
|
||||
"birthdays": {
|
||||
"type": "switch",
|
||||
"label": "Birthday confetti",
|
||||
"default": true,
|
||||
"group": "Birthday"
|
||||
},
|
||||
"birthdayIntensity": {
|
||||
"type": "select",
|
||||
"label": "Intensity",
|
||||
"default": "subtle",
|
||||
"group": "Birthday",
|
||||
"showWhen": "birthdays",
|
||||
"options": [
|
||||
{
|
||||
"value": "subtle",
|
||||
"label": "Subtle"
|
||||
},
|
||||
{
|
||||
"value": "full",
|
||||
"label": "Full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"birthdayTest": {
|
||||
"type": "select",
|
||||
"label": "Force on (test)",
|
||||
"default": "",
|
||||
"group": "Birthday",
|
||||
"showWhen": "birthdays",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Off"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/people"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Band Lineup — server part.
|
||||
*
|
||||
* Owns the shared stage board and the uploaded-photo library, plus the one
|
||||
* Planning Center connection. The board is server-side and global: whoever
|
||||
* edits it (drag a library photo onto a slot, rename, clear) changes it for
|
||||
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
|
||||
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
|
||||
* never talk to Planning Center directly.
|
||||
*
|
||||
* GET /state → { state } (board + library; instant paint)
|
||||
* GET /stream → SSE `state` events (change-detected, heartbeat)
|
||||
* GET /library → { people } (the drag-in photo library)
|
||||
* GET /service-types → { options } (admin "Service type" select)
|
||||
* GET /plans → { options } (admin "Plan" select)
|
||||
* GET /people → { options } (gear "Force birthday" select)
|
||||
* GET /photo/<file> → image bytes (library + cached PCO photos)
|
||||
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
|
||||
* POST /library/<id>/delete→ remove from the library and any slot
|
||||
* POST /assign → ?slot=&personId= place a library person in a slot
|
||||
* POST /move → ?from=&to= move or swap one slot's occupant
|
||||
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
|
||||
* POST /slot/<index>/clear → empty the slot
|
||||
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
|
||||
* POST /sync → run a Planning Center + photo-folder sync now
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const SERVICES = '/services/v2';
|
||||
const PEOPLE = '/people/v2';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
const PHOTO_TIMEOUT_MS = 15000;
|
||||
const MIN_POLL_S = 10;
|
||||
const RETRY_MS = 15000;
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const MODULE_ID = 'band-lineup';
|
||||
const MAX_UPLOAD = 20 * 1024 * 1024;
|
||||
|
||||
let current = null; // set by init(), read by routes; rebuilt together on remount
|
||||
|
||||
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
|
||||
|
||||
function resolveDataDir() {
|
||||
const home = os.homedir();
|
||||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
if (fromEnv) return path.resolve(root, fromEnv);
|
||||
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');
|
||||
}
|
||||
|
||||
/* ── small helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
|
||||
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
|
||||
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||||
function birthdayMmdd(raw) {
|
||||
const s = String(raw || '').trim();
|
||||
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
||||
if (full) return `${full[1]}-${full[2]}`;
|
||||
return /^\d{2}-\d{2}$/.test(s) ? s : '';
|
||||
}
|
||||
function birthdayWeekSet(now = new Date()) {
|
||||
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||
const set = new Set();
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
function extFromContentType(ct) {
|
||||
const t = String(ct || '').toLowerCase();
|
||||
if (t.includes('png')) return 'png';
|
||||
if (t.includes('webp')) return 'webp';
|
||||
if (t.includes('gif')) return 'gif';
|
||||
return 'jpg';
|
||||
}
|
||||
function contentTypeForFile(file) {
|
||||
const e = path.extname(file).toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
|
||||
function newId() { return crypto.randomBytes(6).toString('hex'); }
|
||||
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
|
||||
|
||||
/* ── 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');
|
||||
const configured = Boolean(auth);
|
||||
|
||||
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) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
async function getAll(target, params) {
|
||||
const data = []; const included = []; let next = null;
|
||||
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
|
||||
do {
|
||||
const body = next ? await get(next) : await get(target, query);
|
||||
if (Array.isArray(body.data)) data.push(...body.data);
|
||||
if (Array.isArray(body.included)) included.push(...body.included);
|
||||
next = body?.links?.next || null; query = null; guard += 1;
|
||||
} while (next && guard < 50);
|
||||
return { data, included };
|
||||
}
|
||||
return { configured, get, getAll };
|
||||
}
|
||||
|
||||
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
|
||||
|
||||
const DRIVE_TIMEOUT_MS = 30000;
|
||||
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
|
||||
|
||||
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
|
||||
function parseDriveFolderId(s) {
|
||||
const str = String(s || '');
|
||||
if (!/drive\.google\.com/i.test(str)) return '';
|
||||
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
|
||||
async function driveList(folderId) {
|
||||
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
|
||||
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
|
||||
const html = await resp.text();
|
||||
const out = []; const seen = new Set();
|
||||
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const id = m[1]; const name = m[2].trim();
|
||||
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
|
||||
async function driveFetchImage(fileId) {
|
||||
const base = 'https://drive.usercontent.google.com/download';
|
||||
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
|
||||
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
|
||||
let ct = String(resp.headers.get('content-type') || '');
|
||||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||
// Large-file virus-scan interstitial: grab the confirm token and retry.
|
||||
if (ct.includes('text/html')) {
|
||||
const html = await resp.text();
|
||||
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
|
||||
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
|
||||
if (conf) {
|
||||
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
|
||||
ct = String(resp.headers.get('content-type') || '');
|
||||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||
}
|
||||
}
|
||||
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
|
||||
const tct = String(th.headers.get('content-type') || '');
|
||||
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
|
||||
throw new Error('not an image');
|
||||
}
|
||||
|
||||
/* ── the module ────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const dataDir = path.join(resolveDataDir(), MODULE_ID);
|
||||
const photoDir = path.join(dataDir, 'photos');
|
||||
const libraryPath = path.join(dataDir, 'library.json');
|
||||
const boardPath = path.join(dataDir, 'board.json');
|
||||
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
|
||||
|
||||
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
|
||||
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
|
||||
const columns = Math.max(1, Number(config.columns) || 7);
|
||||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||
const planIdCfg = String(config.planId || '').trim();
|
||||
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
|
||||
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
|
||||
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
|
||||
const settingsPath = path.join(dataDir, 'settings.json');
|
||||
const settings = readJsonFile(settingsPath, {}) || {};
|
||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
|
||||
// so its assignment persists in board.json across toggles.
|
||||
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
|
||||
|
||||
const pco = createPcoClient(config);
|
||||
|
||||
// Persisted, shared editor state.
|
||||
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
|
||||
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
|
||||
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
|
||||
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
|
||||
|
||||
let latest = clearedState();
|
||||
let lastFrame = '';
|
||||
let celebrateTimer = null;
|
||||
let drivePollTimer = null;
|
||||
let watcher = null;
|
||||
let watchDebounce = null;
|
||||
let stopped = false;
|
||||
let syncing = false;
|
||||
|
||||
function clearedState() {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
}
|
||||
|
||||
function libraryList() {
|
||||
return Object.entries(library)
|
||||
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (!streams.size) return;
|
||||
let frame; try { frame = JSON.stringify(latest); } catch { return; }
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
|
||||
}
|
||||
|
||||
async function listPlans(stId) {
|
||||
const [future, past] = await Promise.all([
|
||||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
|
||||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
|
||||
]);
|
||||
const rows = [...(future.data || []), ...(past.data || [])];
|
||||
return rows.map((rec) => ({
|
||||
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
|
||||
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
|
||||
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
|
||||
}));
|
||||
}
|
||||
function pickPlan(plans) {
|
||||
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
|
||||
const todayY = localYmd(new Date());
|
||||
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
|
||||
if (today) return today;
|
||||
const now = Date.now();
|
||||
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
|
||||
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
|
||||
}
|
||||
|
||||
async function cachePcoPhoto(personId, url) {
|
||||
if (!url) return '';
|
||||
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
|
||||
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
|
||||
if (existing) return existing;
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
|
||||
if (!resp.ok) return '';
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
if (!buf.length) return '';
|
||||
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
|
||||
fs.writeFileSync(path.join(photoDir, file), buf);
|
||||
return file;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a folder of photos into the library and keep it in sync: new files are added
|
||||
* (filename → name), changed files refreshed, and files removed from the folder dropped
|
||||
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
|
||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||
*/
|
||||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||
function scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
|
||||
const files = names.filter((n) => IMG_RE.test(n));
|
||||
const present = new Set(files);
|
||||
const byKey = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
|
||||
let changed = false;
|
||||
for (const f of files) {
|
||||
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
|
||||
const id = byKey.get(f);
|
||||
try {
|
||||
if (id) {
|
||||
const v = library[id];
|
||||
if (v.mtime === mtime && v.file) continue; // unchanged
|
||||
const dest = `lib-${id}.${extOf(f)}`;
|
||||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
|
||||
} else {
|
||||
const nid = newId();
|
||||
const dest = `lib-${nid}.${extOf(f)}`;
|
||||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
|
||||
changed = true;
|
||||
}
|
||||
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
|
||||
}
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey && !present.has(v.folderKey)) {
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
delete library[id];
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
|
||||
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
|
||||
// where someone uploaded people by hand and then pointed at a folder of the same people.
|
||||
const folderByName = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey || !v.name) continue;
|
||||
const keepId = folderByName.get(normName(v.name));
|
||||
if (!keepId) continue;
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||
delete library[id];
|
||||
changed = true;
|
||||
}
|
||||
if (changed) { saveLibrary(); saveBoard(); }
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
|
||||
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
|
||||
async function scanDrive(folderId) {
|
||||
let files;
|
||||
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
|
||||
files = files.filter((f) => IMG_RE.test(f.name));
|
||||
const present = new Set(files.map((f) => `drive:${f.id}`));
|
||||
const have = new Set();
|
||||
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
|
||||
let changed = false;
|
||||
for (const f of files) {
|
||||
const key = `drive:${f.id}`;
|
||||
if (have.has(key)) continue; // already imported (by Drive file id)
|
||||
let img;
|
||||
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
|
||||
const nid = newId();
|
||||
const dest = `lib-${nid}.${img.ext}`;
|
||||
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
|
||||
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
|
||||
}
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
delete library[id];
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
|
||||
const driveByName = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
|
||||
const keepId = driveByName.get(normName(v.name));
|
||||
if (!keepId) continue;
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||
delete library[id]; changed = true;
|
||||
}
|
||||
if (changed) { saveLibrary(); saveBoard(); }
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Import the configured photo source — a Drive folder link, or a local folder path.
|
||||
async function syncPhotos() {
|
||||
const fid = parseDriveFolderId(photoFolder);
|
||||
if (fid) return scanDrive(fid);
|
||||
if (photoFolder) return scanFolder();
|
||||
return false;
|
||||
}
|
||||
|
||||
function startWatch() {
|
||||
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
|
||||
if (!photoFolder) return;
|
||||
try {
|
||||
watcher = fs.watch(photoFolder, { persistent: false }, () => {
|
||||
clearTimeout(watchDebounce);
|
||||
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
|
||||
});
|
||||
log(`watching photo folder ${photoFolder}`);
|
||||
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
|
||||
}
|
||||
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
|
||||
|
||||
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
|
||||
function prunePcoPhotos(keep) {
|
||||
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
|
||||
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
|
||||
}
|
||||
|
||||
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
// Resolve one board slot to a display person (or null). The board is the source of truth —
|
||||
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
|
||||
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
|
||||
function resolveSlot(i, weekSet, keep) {
|
||||
const e = board[String(i)];
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
if (e.pco) {
|
||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||
manual = false; personId = e.pco.personId || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
personId = e.personId;
|
||||
name = String(e.name || lib.name || '').trim();
|
||||
file = lib.file || ''; birthday = lib.birthday || '';
|
||||
} else if (e.name) {
|
||||
name = String(e.name).trim();
|
||||
} else return null;
|
||||
if (file) keep.add(file);
|
||||
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
|
||||
}
|
||||
|
||||
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
|
||||
// and on a slow timer so a birthday week rolling over lights up on its own.
|
||||
function renderBoard() {
|
||||
const weekSet = birthdayWeekSet();
|
||||
const keep = new Set();
|
||||
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
|
||||
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
|
||||
prunePcoPhotos(keep);
|
||||
latest = {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
|
||||
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildLocal() { renderBoard(); broadcast(); }
|
||||
|
||||
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
|
||||
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
|
||||
async function syncNow() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
try {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
|
||||
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
|
||||
const members = []; const seen = new Set();
|
||||
for (const m of tm.data || []) {
|
||||
const status = String(m.attributes?.status || '').trim();
|
||||
if (status === 'D' || /^declined$/i.test(status)) continue;
|
||||
const personId = String(m.relationships?.person?.data?.id || '');
|
||||
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
|
||||
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
|
||||
const person = byKey.get(`Person:${personId}`);
|
||||
members.push({
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
}
|
||||
const bdays = new Map();
|
||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
const eff = effectiveSlots();
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
|
||||
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
|
||||
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
|
||||
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
|
||||
} // end else (plan found)
|
||||
} // end if (Planning Center configured)
|
||||
} catch (err) {
|
||||
latest.reachable = false; latest.lastError = err?.message || String(err);
|
||||
log(`sync failed: ${err?.message || err}`);
|
||||
} finally {
|
||||
syncing = false;
|
||||
renderBoard(); broadcast();
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
|
||||
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
|
||||
celebrateTimer.unref?.();
|
||||
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
|
||||
// in Drive show up without a manual Sync.
|
||||
drivePollTimer = setInterval(() => {
|
||||
if (stopped || !parseDriveFolderId(photoFolder)) return;
|
||||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
|
||||
}, 5 * 60 * 1000);
|
||||
drivePollTimer.unref?.();
|
||||
|
||||
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
|
||||
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
|
||||
rebuildLocal(); // paint the saved board immediately
|
||||
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
|
||||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
|
||||
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
|
||||
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
|
||||
if (watchDebounce) clearTimeout(watchDebounce);
|
||||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||||
streams.clear(); if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
|
||||
if (latest.lastError) return { status: 'error', message: latest.lastError };
|
||||
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
|
||||
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes({ log }) {
|
||||
// Read an image request body (raw bytes) with a size cap.
|
||||
function readBody(req, cb) {
|
||||
const chunks = []; let size = 0; let bad = false;
|
||||
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
|
||||
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
|
||||
req.on('error', () => cb(null));
|
||||
}
|
||||
|
||||
return {
|
||||
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : null }),
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
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');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getLatest() : null)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
|
||||
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
|
||||
|
||||
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
|
||||
'POST /library/*': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||
const del = /\/delete$/.test(rest);
|
||||
const id = rest.replace(/\/delete$/, '').trim();
|
||||
const entry = current.library[id];
|
||||
if (!entry) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (del) {
|
||||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||
delete current.library[id];
|
||||
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
|
||||
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
readBody(req, (buf) => {
|
||||
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
|
||||
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
|
||||
if (buf && buf.length) {
|
||||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
|
||||
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
|
||||
}
|
||||
current.saveLibrary(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
},
|
||||
|
||||
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
|
||||
'POST /choir': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const p = new URLSearchParams(req.search || '');
|
||||
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
|
||||
current.setChoir(on);
|
||||
json(res, 200, { ok: true, choir: on });
|
||||
},
|
||||
|
||||
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
|
||||
'POST /move': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const p = new URLSearchParams(req.search || '');
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
|
||||
'POST /assign': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
const slot = String(q.get('slot') || '').trim();
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
|
||||
'POST /slot/*': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||
const clearing = /\/clear$/.test(rest);
|
||||
const index = rest.replace(/\/(clear|name)$/, '').trim();
|
||||
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
|
||||
// runs on its own timer; this just does it now.
|
||||
'POST /sync': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
'GET /people': (req, res) => {
|
||||
const state = current ? current.getLatest() : null;
|
||||
const options = [{ value: '', label: 'Off' }];
|
||||
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
|
||||
json(res, 200, { options });
|
||||
},
|
||||
|
||||
'GET /photo/*': (req, res) => {
|
||||
if (!current) return void res.writeHead(404).end();
|
||||
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
|
||||
if (!name || name.includes('..')) return void res.writeHead(400).end();
|
||||
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
|
||||
if (err) return void res.writeHead(404).end();
|
||||
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
|
||||
res.end(buf);
|
||||
});
|
||||
},
|
||||
|
||||
'GET /service-types': async (req, res) => {
|
||||
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
|
||||
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||
try {
|
||||
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
|
||||
const folders = new Map();
|
||||
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
|
||||
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 = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).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) }); }
|
||||
},
|
||||
|
||||
'GET /plans': async (req, res) => {
|
||||
if (!current) return json(res, 503, { options: [] });
|
||||
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 (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||
try {
|
||||
const plans = await current.listPlans(stId); const now = Date.now();
|
||||
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
|
||||
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
|
||||
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
/* Band Lineup — scoped to the tile, themed only through the shell's variables. */
|
||||
|
||||
[data-module="band-lineup"] .bl-board {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||
grid-template-rows: repeat(var(--bl-rows, 2), minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 16px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-slot {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-photo {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg) 70%, black);
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
display: block;
|
||||
transition: transform 0.12s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-initials {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4em;
|
||||
font-weight: 800;
|
||||
color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-name {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
max-width: calc(100% - 12px);
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.74em;
|
||||
font-weight: 700;
|
||||
line-height: 1.12;
|
||||
z-index: 5;
|
||||
/* Show the whole name — wrap onto more lines rather than clipping with an ellipsis. */
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-label {
|
||||
padding: 5px 6px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 1.6em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-empty {
|
||||
border-style: dashed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-empty .bl-label {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
margin: auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- Birthday effects (confetti canvas + gold glow ring) ---- */
|
||||
[data-module="band-lineup"] .bl-confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-birthday-subtle {
|
||||
animation: bl-glow-subtle 3.4s ease-in-out infinite;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-birthday-full {
|
||||
animation: bl-glow-full 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bl-glow-subtle {
|
||||
0%, 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
50% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent); }
|
||||
}
|
||||
@keyframes bl-glow-full {
|
||||
0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 20px color-mix(in srgb, var(--accent) 42%, transparent); }
|
||||
50% { box-shadow: inset 0 0 0 3px var(--accent), 0 0 34px color-mix(in srgb, var(--accent) 66%, transparent); }
|
||||
}
|
||||
|
||||
/* ---- Editor (Edit button): board drop-targets + uploaded-photo library ---- */
|
||||
[data-module="band-lineup"] .bl-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 5px 11px;
|
||||
font: inherit;
|
||||
font-size: 0.82em;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-btn.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
[data-module="band-lineup"] .bl-btn:hover { border-color: var(--accent); }
|
||||
|
||||
[data-module="band-lineup"] .bl-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
[data-module="band-lineup"] .bl-editor-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-editor-bar b { color: var(--text); }
|
||||
[data-module="band-lineup"] .bl-editor-spacer { flex: 1; }
|
||||
[data-module="band-lineup"] .bl-folder { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
[data-module="band-lineup"] .bl-folder-path { font-size: 0.72em; color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="band-lineup"] .bl-editor-body { flex: 1; min-height: 0; overflow: auto; padding: 10px; }
|
||||
|
||||
[data-module="band-lineup"] .bl-edit-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-slot {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-slot.bl-drop-over { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
[data-module="band-lineup"] .bl-edit-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||
[data-module="band-lineup"] .bl-edit-photo .bl-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-edit-drop {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 1.6em; border: 1px dashed var(--border);
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-namerow { display: flex; align-items: center; gap: 2px; padding: 2px; }
|
||||
[data-module="band-lineup"] .bl-edit-name {
|
||||
flex: 1; min-width: 0; text-align: left; background: transparent; border: 0;
|
||||
color: var(--text); font: inherit; font-size: 0.72em; font-weight: 600; padding: 3px 4px;
|
||||
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-name:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
[data-module="band-lineup"] .bl-edit-clear {
|
||||
flex: 0 0 auto; width: 20px; height: 20px; border: 0; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); cursor: pointer; font-size: 0.9em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-label { padding: 3px 5px 5px; font-size: 0.64em; color: var(--muted); text-align: center; border-top: 1px solid var(--border); word-break: break-word; }
|
||||
|
||||
[data-module="band-lineup"] .bl-library {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-empty { grid-column: 1 / -1; color: var(--muted); font-size: 0.85em; padding: 10px 2px; }
|
||||
[data-module="band-lineup"] .bl-lib-item {
|
||||
position: relative; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||
background: var(--panel); cursor: grab; display: flex; flex-direction: column;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-item:active { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-lib-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
[data-module="band-lineup"] .bl-lib-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||
[data-module="band-lineup"] .bl-lib-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-lib-name {
|
||||
border: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.68em; font-weight: 600;
|
||||
padding: 3px 4px; text-align: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del {
|
||||
position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; border: 0; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"id": "clock",
|
||||
"name": "Clock",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
||||
"client": "client.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
@@ -18,7 +17,7 @@
|
||||
{ "value": "12h", "label": "12-hour" }
|
||||
]
|
||||
},
|
||||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
|
||||
"showDate": { "type": "boolean", "label": "Show date", "default": false }
|
||||
"showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
|
||||
"showDate": { "type": "boolean", "label": "Date", "default": false }
|
||||
}
|
||||
}
|
||||
|
||||
+226
-25
@@ -1,14 +1,35 @@
|
||||
/* PCO Plan — client part.
|
||||
|
||||
The module presents three tiles and this is the entry for all of them: the
|
||||
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
||||
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
||||
|
||||
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. */
|
||||
colors, item-note categories); the text size is the title bar's A− / A+
|
||||
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||
the connection settings. */
|
||||
|
||||
import createTeams from "./teams-client.js";
|
||||
import createKey from "./key-client.js";
|
||||
import { prettyKey } from "./key-notation.js";
|
||||
|
||||
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>';
|
||||
|
||||
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
// Each picker entry carries its variant and its own settings.
|
||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
||||
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
|
||||
return createPlan({ root, moduleApi });
|
||||
}
|
||||
|
||||
function createPlan({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let skew = 0; // server clock − local clock, ms
|
||||
let feedOffline = false;
|
||||
@@ -16,6 +37,7 @@ export default function create({ root, moduleApi }) {
|
||||
let ticker = null;
|
||||
let lastCurrentId = null;
|
||||
let lastListKey = '';
|
||||
let absView = null; // what the server says AbleSet is doing, if anything
|
||||
let userScrolledAt = 0;
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -53,7 +75,7 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
const refreshBtn = moduleApi.header.addButton({
|
||||
icon: REFRESH_SVG,
|
||||
title: 'Re-read the plan from Planning Center now',
|
||||
title: 'Refresh',
|
||||
onClick() {
|
||||
refreshBtn.classList.add('active');
|
||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||
@@ -67,8 +89,8 @@ export default function create({ root, moduleApi }) {
|
||||
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) });
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||
|
||||
/* ── formatting ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -87,6 +109,16 @@ export default function create({ root, moduleApi }) {
|
||||
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** "Starts in": under a day like fmtDur; a day or more reads "2 Days, 05:12:44". */
|
||||
function fmtStartsIn(totalSec) {
|
||||
const s = Math.max(0, Math.round(totalSec));
|
||||
const days = Math.floor(s / 86400);
|
||||
if (!days) return fmtDur(s);
|
||||
const rest = s % 86400;
|
||||
const pad = (v) => String(v).padStart(2, '0');
|
||||
return `${days} ${days === 1 ? 'Day' : 'Days'}, ${pad(Math.floor(rest / 3600))}:${pad(Math.floor((rest % 3600) / 60))}:${pad(rest % 60)}`;
|
||||
}
|
||||
|
||||
function fmtMinutes(totalSec) {
|
||||
const m = Math.round(totalSec / 60);
|
||||
if (m < 60) return `${m} min`;
|
||||
@@ -121,10 +153,19 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Which note categories this tile shows. Unset (never chosen in the gear
|
||||
* window) means every category, including ones Planning Center gains
|
||||
* later; an array is exactly those, by name. A layout saved before the
|
||||
* categories were a list holds a comma-separated string — read as that
|
||||
* list, blank as unset.
|
||||
*/
|
||||
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;
|
||||
const raw = cfg().noteCategories;
|
||||
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
||||
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
||||
if (!Array.isArray(raw) && !wanted.size) return () => true;
|
||||
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
/* ── status dot ─────────────────────────────────────────────────── */
|
||||
@@ -209,36 +250,55 @@ export default function create({ root, moduleApi }) {
|
||||
renderClock();
|
||||
}
|
||||
|
||||
/**
|
||||
* The header clock follows the service the server says owns the clock
|
||||
* (state.service: idle → running → stopped, by schedule or by the admin's
|
||||
* ProPresenter document cues): a countdown before it, the running time
|
||||
* while it runs (with ahead/behind when following ProPresenter), the final
|
||||
* time once it has ended.
|
||||
*/
|
||||
function renderClock() {
|
||||
const plan = state?.plan;
|
||||
const live = state?.live || {};
|
||||
const svc = state?.service || {};
|
||||
clockLabelEl.textContent = '';
|
||||
clockValueEl.textContent = '';
|
||||
clockValueEl.className = 'pp-clock-value';
|
||||
if (!plan || headEl.hidden) return;
|
||||
const now = serverNow();
|
||||
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
|
||||
const startedAt = svc.startedAt || live.serviceStartedAt || 0;
|
||||
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
|
||||
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');
|
||||
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
||||
if (live.following) {
|
||||
// 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);
|
||||
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
|
||||
clockLabelEl.textContent = 'Ended';
|
||||
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
|
||||
return;
|
||||
}
|
||||
if (on('showRunningClock') && plan.serviceStartsAt) {
|
||||
clockLabelEl.textContent = 'Since start';
|
||||
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
||||
if (on('showCountdown') && startsAt > now) {
|
||||
clockLabelEl.textContent = 'Starts in';
|
||||
clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
|
||||
return;
|
||||
}
|
||||
if (on('showRunningClock') && startsAt) {
|
||||
// Past the scheduled start and not started: waiting for the admin's
|
||||
// start document to go live in ProPresenter (or its failsafe).
|
||||
clockLabelEl.textContent = 'Scheduled';
|
||||
clockValueEl.textContent = fmtClock(startsAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +370,8 @@ export default function create({ root, moduleApi }) {
|
||||
if (on('showKey') && item.keyName) {
|
||||
const key = document.createElement('span');
|
||||
key.className = 'pp-key';
|
||||
key.textContent = item.keyName;
|
||||
// B♭, F♯m — the same notation the Song Key tile uses.
|
||||
key.textContent = prettyKey(item.keyName);
|
||||
titleLine.appendChild(key);
|
||||
}
|
||||
if (on('showNowIndicator')) {
|
||||
@@ -437,6 +498,133 @@ export default function create({ root, moduleApi }) {
|
||||
renderLiveMarks();
|
||||
}
|
||||
|
||||
/* ── AbleSet: the song playing under the current item ──────────────
|
||||
Everything here draws only when the server says there is an AbleSet
|
||||
module, it is configured and reachable, and the song it is playing is
|
||||
the plan item on screen. Any of those missing and the tile looks
|
||||
exactly as it did before AbleSet existed. */
|
||||
|
||||
/** The playhead now, in beats, carried forward from the last packet. */
|
||||
function absBeatsNow() {
|
||||
if (!absView || absView.beats == null) return null;
|
||||
if (!absView.playing || !absView.tempo) return absView.beats;
|
||||
const since = Math.max(0, serverNow() - absView.at) / 1000;
|
||||
return absView.beats + since * (absView.tempo / 60);
|
||||
}
|
||||
|
||||
function absFraction(part) {
|
||||
const beats = absBeatsNow();
|
||||
if (beats == null || !part || part.start == null || part.end == null) return null;
|
||||
const span = part.end - part.start;
|
||||
if (!(span > 0)) return null;
|
||||
return Math.max(0, Math.min(1, (beats - part.start) / span));
|
||||
}
|
||||
|
||||
/** The row AbleSet's song belongs to — only ever the current one. */
|
||||
function absRow() {
|
||||
const a = absView;
|
||||
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
||||
if (a.itemId !== lastCurrentId) return null;
|
||||
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
||||
}
|
||||
|
||||
/** Keep the playing section visible in the strip — this strip, nothing else. */
|
||||
function absScrollSections(strip) {
|
||||
const active = strip.querySelector('.pp-abs-sec.is-now');
|
||||
if (!active) return;
|
||||
const box = strip.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function renderAbleSet() {
|
||||
const row = absRow();
|
||||
|
||||
// Anything left on a row that is no longer the one goes.
|
||||
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
|
||||
if (!row || !row.contains(stale)) stale.remove();
|
||||
}
|
||||
if (!row) return;
|
||||
|
||||
if (on('absProgress')) {
|
||||
const fraction = absFraction(absView.song);
|
||||
if (fraction == null) {
|
||||
row.querySelector('.pp-fill')?.remove();
|
||||
} else {
|
||||
let fill = row.querySelector('.pp-fill');
|
||||
if (!fill) {
|
||||
fill = document.createElement('div');
|
||||
fill.className = 'pp-fill';
|
||||
row.prepend(fill); // behind the row's content, which the stylesheet lifts
|
||||
}
|
||||
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
|
||||
}
|
||||
} else {
|
||||
row.querySelector('.pp-fill')?.remove();
|
||||
}
|
||||
|
||||
if (on('absSections') && absView.sections.length) {
|
||||
const main = row.querySelector('.pp-col-main');
|
||||
let strip = row.querySelector('.pp-abs-sections');
|
||||
if (!strip && main) {
|
||||
strip = document.createElement('div');
|
||||
strip.className = 'pp-abs-sections';
|
||||
main.appendChild(strip);
|
||||
}
|
||||
if (strip) {
|
||||
// The chips are rebuilt only when the song's sections change. Moving
|
||||
// on to the next section just moves the classes: rebuilding would
|
||||
// throw the strip's scroll away thirteen times a song.
|
||||
const key = absView.sections.map((s) => s.name).join('\u0000');
|
||||
if (strip.dataset.key !== key) {
|
||||
strip.dataset.key = key;
|
||||
strip.dataset.now = '';
|
||||
strip.innerHTML = '';
|
||||
for (const section of absView.sections) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'pp-abs-sec';
|
||||
chip.textContent = section.name;
|
||||
strip.appendChild(chip);
|
||||
}
|
||||
}
|
||||
const now = absView.section?.index ?? -1;
|
||||
if (strip.dataset.now !== String(now)) {
|
||||
strip.dataset.now = String(now);
|
||||
[...strip.children].forEach((chip, i) => {
|
||||
chip.classList.toggle('is-now', i === now);
|
||||
chip.classList.toggle('is-done', i < now);
|
||||
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
|
||||
});
|
||||
requestAnimationFrame(() => absScrollSections(strip));
|
||||
}
|
||||
const active = strip.children[now];
|
||||
if (active) {
|
||||
const fraction = absFraction(absView.section);
|
||||
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
row.querySelector('.pp-abs-sections')?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
|
||||
* scroll every ancestor that can scroll with it, which on a dashboard taller
|
||||
* than the window drags the whole board; moving `listEl.scrollTop` moves
|
||||
* this tile and nothing else. Measured from rectangles rather than
|
||||
* `offsetTop` because the rows' offset parent is the tile, not the list.
|
||||
*/
|
||||
function keepInView(row) {
|
||||
const list = listEl.getBoundingClientRect();
|
||||
const rect = row.getBoundingClientRect();
|
||||
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||
function renderLiveMarks() {
|
||||
const live = state?.live || {};
|
||||
@@ -475,11 +663,12 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||
}
|
||||
renderAbleSet();
|
||||
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' });
|
||||
if (row) keepInView(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,6 +743,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function tick() {
|
||||
renderAbleSet();
|
||||
if (!state?.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
@@ -565,6 +755,7 @@ export default function create({ root, moduleApi }) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||
if (payload.ableset) absView = payload.ableset;
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
@@ -585,6 +776,16 @@ export default function create({ root, moduleApi }) {
|
||||
accept(JSON.parse(ev.data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
},
|
||||
// Its own event, several a second while a song plays: redraw that one
|
||||
// row rather than the whole list.
|
||||
ableset(ev) {
|
||||
try {
|
||||
absView = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (state) renderAbleSet();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* PCO Plan — the Song Key tile.
|
||||
|
||||
One thing, big: the key of the plan item on screen. It reads the same
|
||||
streamed state the Plan tile does, so it needs no connection of its own;
|
||||
which item is current comes from the ProPresenter module, as ever. */
|
||||
|
||||
import { parseKey, prettyKey } from "./key-notation.js";
|
||||
|
||||
export default function createKey({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let feedOffline = false;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="pk-wrap">
|
||||
<div class="pk-key"></div>
|
||||
<div class="pk-name"></div>
|
||||
<div class="pk-next"></div>
|
||||
</div>`;
|
||||
|
||||
const wrap = root.querySelector('.pk-wrap');
|
||||
const keyEl = root.querySelector('.pk-key');
|
||||
const nameEl = root.querySelector('.pk-name');
|
||||
const nextEl = root.querySelector('.pk-next');
|
||||
|
||||
const cfg = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||
|
||||
/* ── the item on screen, and the one after it ───────────────────── */
|
||||
|
||||
function items() {
|
||||
return Array.isArray(state?.items) ? state.items : [];
|
||||
}
|
||||
|
||||
function currentItem() {
|
||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
||||
return id ? items().find((it) => it.id === id) || null : null;
|
||||
}
|
||||
|
||||
/** The next item that actually has a key — a talk between two songs is skipped. */
|
||||
function nextKeyed(after) {
|
||||
const list = items();
|
||||
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
|
||||
if (from <= 0) return null;
|
||||
for (let i = from; i < list.length; i += 1) {
|
||||
if (list[i].keyName) return list[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function titleOf(item) {
|
||||
return item ? (item.song?.title || item.title || '') : '';
|
||||
}
|
||||
|
||||
/* ── render ─────────────────────────────────────────────────────── */
|
||||
|
||||
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 item = currentItem();
|
||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
||||
// The key on screen stays — the band still needs it — but a tile must
|
||||
// never look confidently live while the feed that chose it is gone.
|
||||
if (!state.live.available) {
|
||||
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
|
||||
}
|
||||
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
|
||||
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
|
||||
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
|
||||
* better with the accidental raised and the minor kept small.
|
||||
*/
|
||||
function drawKey(text) {
|
||||
keyEl.innerHTML = '';
|
||||
const parts = parseKey(text);
|
||||
if (!parts) {
|
||||
keyEl.textContent = text;
|
||||
return;
|
||||
}
|
||||
keyEl.appendChild(document.createTextNode(parts.note));
|
||||
if (parts.accidental) {
|
||||
const acc = document.createElement('span');
|
||||
acc.className = 'pk-accidental';
|
||||
acc.textContent = parts.accidental;
|
||||
keyEl.appendChild(acc);
|
||||
}
|
||||
if (parts.quality) {
|
||||
const tail = document.createElement('span');
|
||||
tail.className = 'pk-quality';
|
||||
tail.textContent = parts.quality;
|
||||
keyEl.appendChild(tail);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
const live = state?.live;
|
||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
||||
const color = String(cfg().keyColor || '').trim();
|
||||
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
|
||||
renderStatus();
|
||||
|
||||
const item = currentItem();
|
||||
const key = item?.keyName || '';
|
||||
wrap.classList.toggle('has-key', Boolean(key));
|
||||
|
||||
if (!state || !state.configured || !state.serviceTypeSet) {
|
||||
drawKey('—');
|
||||
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
|
||||
nextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!item) {
|
||||
drawKey('—');
|
||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
||||
nextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
drawKey(key || '—');
|
||||
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
|
||||
nameEl.hidden = !nameEl.textContent;
|
||||
|
||||
const next = on('showNextKey') ? nextKeyed(item) : null;
|
||||
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
|
||||
nextEl.hidden = !nextEl.textContent;
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
function accept(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
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 */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
return {
|
||||
start() {
|
||||
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();
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onConfigChange() {
|
||||
// Admin holds the connection only; the server re-inits and re-sends.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* PCO Plan — how a song key is written.
|
||||
|
||||
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
|
||||
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
|
||||
on an item and the Song Key tile all agree. Shared rather than repeated
|
||||
because the day one of them changes, they all should. */
|
||||
|
||||
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
|
||||
export function parseKey(raw) {
|
||||
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
|
||||
if (!match) return null;
|
||||
const [, note, accidental, quality] = match;
|
||||
return {
|
||||
note,
|
||||
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
|
||||
quality: quality.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The same thing as one string, for a badge or a caption. */
|
||||
export function prettyKey(raw) {
|
||||
const parts = parseKey(raw);
|
||||
if (!parts) return String(raw || '').trim();
|
||||
return `${parts.note}${parts.accidental}${parts.quality}`;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Title matching for the PCO Plan module — the one answer to "is this
|
||||
* ProPresenter document that Planning Center thing?", used three ways:
|
||||
*
|
||||
* - mapping the document ProPresenter has live to a plan item (the
|
||||
* highlight and the item runtimes) — placeDocument(), below,
|
||||
* - recognising the message document, which is named after the sermon
|
||||
* and never after its plan item — the message rule, below, and
|
||||
* - recognising the documents an admin picked as the service start / end
|
||||
* cues (see service-timing.js).
|
||||
*
|
||||
* A ProPresenter name and a Planning Center title rarely agree letter for
|
||||
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
|
||||
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
|
||||
* leading date codes and dash separators dropped), lower-cased, stripped of
|
||||
* punctuation and compared:
|
||||
*
|
||||
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
|
||||
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
|
||||
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
|
||||
*
|
||||
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
|
||||
*
|
||||
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
|
||||
* the item that scores best *and* is next in the
|
||||
* plan wins, so a near miss lands on the right row.
|
||||
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
|
||||
* clock, so one wrong word must not fire it: the
|
||||
* pre-service loop (0.67 against "Post Service Loop")
|
||||
* stays a non-match, while a renamed
|
||||
* "Post-Service Loop", a tagged "Post Service Loop
|
||||
* [Full]" (1.0) or a shortened "Post Service" (0.85)
|
||||
* still trigger.
|
||||
*/
|
||||
|
||||
const MATCH_THRESHOLD = 0.6;
|
||||
const TRIGGER_THRESHOLD = 0.75;
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Does a live ProPresenter document count as the cue document an admin picked? */
|
||||
function isCue(liveName, cueDocument) {
|
||||
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||
}
|
||||
|
||||
/* ── the message rule ──────────────────────────────────────────────────
|
||||
A sermon's ProPresenter document is named after the sermon, never after
|
||||
the plan item that stands for it ("Message", "Resi Message"), so no name
|
||||
comparison can place it — and the previous song stayed "current" through
|
||||
a whole message. What the document's name does carry is the plan's own
|
||||
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
|
||||
date, its series title and its title. A live document that reads like
|
||||
the plan is the plan's message, and lands on the plan's message item,
|
||||
however that item happens to be titled. */
|
||||
|
||||
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
|
||||
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
|
||||
/** Undated, the name must read like "Series Title Part #" at least this closely… */
|
||||
const MESSAGE_THRESHOLD = 0.75;
|
||||
/** …dated with this plan's date, it need only resemble the series or the title. */
|
||||
const DATED_MESSAGE_THRESHOLD = 0.6;
|
||||
|
||||
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
|
||||
function leadingDateCode(raw) {
|
||||
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
|
||||
if (!m) return null;
|
||||
const code = m[1];
|
||||
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
|
||||
const mo = Number(code.slice(-4, -2));
|
||||
const d = Number(code.slice(-2));
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
return { y, m: mo, d };
|
||||
}
|
||||
|
||||
/** The local calendar day of an epoch-ms instant, in the same shape. */
|
||||
function dayOf(ms) {
|
||||
const t = new Date(Number(ms));
|
||||
if (!Number.isFinite(t.getTime())) return null;
|
||||
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this live document read like the plan itself — "260927 Where is Your
|
||||
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
|
||||
* 27 Sept 2026? Returns { score, dated } or null.
|
||||
*
|
||||
* A date code naming another day is a firm no: last week's sermon is not
|
||||
* this plan's message. A date code naming the plan's day is strong evidence,
|
||||
* so the rest need only resemble the series or the title. Without a date
|
||||
* the name must read like series + title together — and carry the title
|
||||
* when the plan has both, so a series bumper ("Where is Your Faith?") is
|
||||
* not taken for the sermon.
|
||||
*/
|
||||
function messageDocumentScore(name, plan) {
|
||||
const series = String(plan?.seriesTitle || '').trim();
|
||||
const title = String(plan?.title || '').trim();
|
||||
if (!series && !title) return null;
|
||||
const code = leadingDateCode(name);
|
||||
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
|
||||
if (code && planDay && !sameDay(code, planDay)) return null;
|
||||
const dated = Boolean(code && planDay);
|
||||
const full = [series, title].filter(Boolean).join(' ');
|
||||
const fullScore = similarity(name, full);
|
||||
if (dated) {
|
||||
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
|
||||
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
|
||||
}
|
||||
if (fullScore < MESSAGE_THRESHOLD) return null;
|
||||
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
|
||||
return { score: fullScore, dated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item that stands for the message: an item titled with the word
|
||||
* ("Message", "Resi Message" — a bare "Message" first), else the first item
|
||||
* under a header so titled (a "Sermon" section whose one item is the sermon's
|
||||
* own title). Null when the plan has no such slot.
|
||||
*/
|
||||
function messageItemOf(items) {
|
||||
let best = null;
|
||||
let inSection = false;
|
||||
for (const it of Array.isArray(items) ? items : []) {
|
||||
if (!it) continue;
|
||||
const title = String(it.title || '');
|
||||
if (it.itemType === 'header') {
|
||||
inSection = MESSAGE_WORDS.test(title);
|
||||
continue;
|
||||
}
|
||||
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
|
||||
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
|
||||
if (rank === 3) break;
|
||||
}
|
||||
return best ? best.item : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
|
||||
* builds a Planning Center playlist that way — every plan item, headers
|
||||
* included, each flagged as PCO — until an operator adds something of their
|
||||
* own (a song tag, say), which shifts every position after it. Same length,
|
||||
* headers in the same places and every item PCO-flagged is the test.
|
||||
*/
|
||||
function playlistMirrorsPlan(ppItems, items) {
|
||||
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
|
||||
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
|
||||
}
|
||||
|
||||
/** Best plan item by title, the ones ahead of the current item winning ties. */
|
||||
function bestByName(name, items, all, ctx) {
|
||||
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
|
||||
const hint = ctx?.hint;
|
||||
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
|
||||
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;
|
||||
const atPosition = Boolean(wanted && item.id === wanted.id);
|
||||
const candidate = { item, score, dist, atPosition };
|
||||
if (!best || score > best.score + 0.001) {
|
||||
best = candidate;
|
||||
continue;
|
||||
}
|
||||
if (Math.abs(score - best.score) > 0.001) continue;
|
||||
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
|
||||
}
|
||||
return best ? { item: best.item, score: best.score, via: 'name' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a live ProPresenter document sits in the plan: { item, score, via }
|
||||
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
|
||||
* is { index, mirror } — the document's position in the ProPresenter
|
||||
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
|
||||
*
|
||||
* By name first: the item (or song) title most like the document's name,
|
||||
* the ones ahead of the current item winning ties — a near miss lands on the
|
||||
* right row. Then the message rule: a document that reads like the plan
|
||||
* itself lands on the plan's message item. It overrides a name hit that is
|
||||
* anything short of identical — a song called "Faith" must not catch
|
||||
* "260927 Where is Your Faith? - Part 6" — outright when the document is
|
||||
* dated with the plan's day, and when it reads better otherwise. Position
|
||||
* is never a match on its own: it only breaks a tie between equally good
|
||||
* names, and only while the playlist mirrors the plan.
|
||||
*/
|
||||
function placeDocument(name, ctx) {
|
||||
const all = Array.isArray(ctx?.items) ? ctx.items : [];
|
||||
const items = all.filter((it) => it && it.itemType !== 'header');
|
||||
if (!items.length) return null;
|
||||
const byName = bestByName(name, items, all, ctx);
|
||||
const asMessage = messageDocumentScore(name, ctx?.plan);
|
||||
const messageItem = asMessage ? messageItemOf(all) : null;
|
||||
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
|
||||
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MATCH_THRESHOLD,
|
||||
TRIGGER_THRESHOLD,
|
||||
MESSAGE_THRESHOLD,
|
||||
cleanTitle,
|
||||
normalize,
|
||||
similarity,
|
||||
isCue,
|
||||
leadingDateCode,
|
||||
messageDocumentScore,
|
||||
messageItemOf,
|
||||
playlistMirrorsPlan,
|
||||
placeDocument,
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the PCO Plan module's document matching — placing what
|
||||
* ProPresenter has live onto a plan item, and the message rule.
|
||||
*
|
||||
* node modules/pco-plan/matching.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. The two
|
||||
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
|
||||
* as they stood on 22 Sept, so the expectations read like a Sunday would.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
MESSAGE_THRESHOLD,
|
||||
leadingDateCode,
|
||||
messageDocumentScore,
|
||||
messageItemOf,
|
||||
playlistMirrorsPlan,
|
||||
placeDocument,
|
||||
} = require('./matching');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(` FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
|
||||
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
|
||||
|
||||
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
|
||||
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
|
||||
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
|
||||
|
||||
// North Attleboro — the message is an item called "Message", linked in
|
||||
// ProPresenter to the bumper video; the sermon slides are their own document.
|
||||
const NA = [
|
||||
header('h0', 'Doors Open'),
|
||||
item('1', 'Preservice'),
|
||||
item('2', '5 Minute Countdown', 'media'),
|
||||
item('3', 'MC Announcements', 'media'),
|
||||
header('h4', 'Worship'),
|
||||
item('5', 'Call To Worship', 'media'),
|
||||
item('6', 'Alleluia', 'song', 'Alleluia'),
|
||||
item('7', 'What a God', 'song', 'What a God'),
|
||||
item('8', 'Baptisms', 'media'),
|
||||
item('9', 'Promised Land', 'song', 'Promised Land'),
|
||||
item('10', 'Be Praised', 'song', 'Be Praised'),
|
||||
header('h11', 'Message'),
|
||||
item('12', 'Message'),
|
||||
item('13', 'Standing Salvation Moment', 'media'),
|
||||
header('h14', 'Closing'),
|
||||
item('15', 'Postservice', 'song', '-Postservice'),
|
||||
];
|
||||
const NA_PP = [
|
||||
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
|
||||
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
|
||||
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
|
||||
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
|
||||
pp('Closing', 'header'), pp('Postservice'),
|
||||
];
|
||||
|
||||
// Apollo Beach — the message is the media item "Resi Message".
|
||||
const AB = [
|
||||
item('0', 'Pre Service Meeting', 'media'),
|
||||
header('h1', 'Doors Open'),
|
||||
item('2', 'Pre Service', 'media'),
|
||||
item('3', '5 Minute Countdown', 'media'),
|
||||
item('4', 'MC Announcements', 'media'),
|
||||
header('h5', 'Worship'),
|
||||
item('6', 'Call To Worship', 'media'),
|
||||
item('7', 'Alleluia', 'song', 'Alleluia'),
|
||||
item('8', 'What a God', 'song', 'What a God'),
|
||||
item('9', 'The Blood', 'song', 'The Blood'),
|
||||
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
|
||||
header('h11', 'Message'),
|
||||
item('12', 'Resi Message', 'media'),
|
||||
item('13', 'Standing Salvation Moment', 'media'),
|
||||
header('h14', 'Closing'),
|
||||
item('15', 'Post Service', 'media'),
|
||||
];
|
||||
|
||||
const SERMON = '260927 Where is Your Faith? - Part 6';
|
||||
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
|
||||
const titleOf = (hit) => (hit ? hit.item.title : null);
|
||||
|
||||
console.log('date codes');
|
||||
|
||||
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
|
||||
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
|
||||
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
|
||||
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
|
||||
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
|
||||
assert.strictEqual(leadingDateCode('261340 no such month'), null);
|
||||
assert.strictEqual(leadingDateCode('Alleluia'), null);
|
||||
assert.strictEqual(leadingDateCode(''), null);
|
||||
});
|
||||
|
||||
console.log('the message rule');
|
||||
|
||||
test('a document named like the plan, dated with its day, is the message', () => {
|
||||
const hit = messageDocumentScore(SERMON, PLAN);
|
||||
assert.ok(hit, 'recognised');
|
||||
assert.strictEqual(hit.dated, true);
|
||||
assert.strictEqual(hit.score, 1);
|
||||
});
|
||||
|
||||
test("last week's document is not this plan's message", () => {
|
||||
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
|
||||
});
|
||||
|
||||
test('undated, series and part together still read as the message', () => {
|
||||
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
|
||||
assert.ok(plain && plain.dated === false && plain.score === 1);
|
||||
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
|
||||
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
|
||||
});
|
||||
|
||||
test('a series bumper is not the sermon', () => {
|
||||
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
|
||||
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
|
||||
});
|
||||
|
||||
test("dated with the plan's day, the part alone is enough", () => {
|
||||
const hit = messageDocumentScore('260927 Part 6', PLAN);
|
||||
assert.ok(hit && hit.dated);
|
||||
});
|
||||
|
||||
test('a dated document about something else is not the message', () => {
|
||||
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
|
||||
});
|
||||
|
||||
test('songs are not the message', () => {
|
||||
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
|
||||
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
|
||||
});
|
||||
|
||||
test('a plan with neither series nor title has no message document', () => {
|
||||
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
|
||||
assert.strictEqual(messageDocumentScore(SERMON, null), null);
|
||||
});
|
||||
|
||||
test('a plan without a date still recognises its own name', () => {
|
||||
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
|
||||
assert.ok(hit && hit.dated === false && hit.score === 1);
|
||||
});
|
||||
|
||||
console.log('the message item');
|
||||
|
||||
test('an item called Message is the message item; so is Resi Message', () => {
|
||||
assert.strictEqual(messageItemOf(NA).id, '12');
|
||||
assert.strictEqual(messageItemOf(AB).id, '12');
|
||||
});
|
||||
|
||||
test('a bare "Message" beats an item that only contains the word', () => {
|
||||
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
|
||||
assert.strictEqual(messageItemOf(withBumper).id, '12');
|
||||
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
|
||||
});
|
||||
|
||||
test('under a Sermon header, the first item is the message whatever it is called', () => {
|
||||
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
|
||||
assert.strictEqual(messageItemOf(plan).id, '2');
|
||||
});
|
||||
|
||||
test('a plan without a message slot has none', () => {
|
||||
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
|
||||
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
|
||||
assert.strictEqual(messageItemOf([]), null);
|
||||
assert.strictEqual(messageItemOf(null), null);
|
||||
});
|
||||
|
||||
console.log('the playlist mirror');
|
||||
|
||||
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
|
||||
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
|
||||
});
|
||||
|
||||
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
|
||||
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
|
||||
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
|
||||
const shifted = [...NA_PP];
|
||||
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
|
||||
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
|
||||
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
|
||||
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
|
||||
assert.strictEqual(playlistMirrorsPlan([], []), false);
|
||||
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
|
||||
});
|
||||
|
||||
console.log('placing what is live');
|
||||
|
||||
test('songs and media land by name, tags and all', () => {
|
||||
const god = place('What a God - [ Full ]', NA);
|
||||
assert.strictEqual(titleOf(god), 'What a God');
|
||||
assert.strictEqual(god.via, 'name');
|
||||
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
|
||||
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
|
||||
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
|
||||
});
|
||||
|
||||
test('the sermon document lands on the message item at both campuses', () => {
|
||||
const na = place(SERMON, NA);
|
||||
assert.strictEqual(titleOf(na), 'Message');
|
||||
assert.strictEqual(na.via, 'message document, dated');
|
||||
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
|
||||
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
|
||||
});
|
||||
|
||||
test('the bumper lands on the message item too, by name', () => {
|
||||
const hit = place('Message Bumper', NA);
|
||||
assert.strictEqual(titleOf(hit), 'Message');
|
||||
assert.strictEqual(hit.via, 'name');
|
||||
});
|
||||
|
||||
test('a song called Faith does not catch the sermon', () => {
|
||||
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
|
||||
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
|
||||
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
|
||||
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
|
||||
});
|
||||
|
||||
test('an item titled exactly like the document wins over the message slot', () => {
|
||||
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
|
||||
assert.strictEqual(place(SERMON, titled).item.id, 's');
|
||||
});
|
||||
|
||||
test('a plan without a message slot leaves the sermon document unplaced', () => {
|
||||
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
|
||||
assert.strictEqual(place(SERMON, noSlot), null);
|
||||
});
|
||||
|
||||
test("an unplanned document, or last week's sermon, lands nowhere", () => {
|
||||
assert.strictEqual(place('Offering Slide', NA), null);
|
||||
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
|
||||
});
|
||||
|
||||
test('nothing to place on an empty plan', () => {
|
||||
assert.strictEqual(place(SERMON, []), null);
|
||||
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
|
||||
});
|
||||
|
||||
// The same song twice: a reprise after "What a God".
|
||||
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
|
||||
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
|
||||
|
||||
test('a tie between equal names goes to the one ahead of the current item', () => {
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
|
||||
});
|
||||
|
||||
test('position breaks a tie only while the playlist mirrors the plan', () => {
|
||||
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
|
||||
assert.strictEqual(mirror, true);
|
||||
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
|
||||
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
|
||||
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
|
||||
});
|
||||
|
||||
test('position never places a document on its own', () => {
|
||||
const mirror = playlistMirrorsPlan(NA_PP, NA);
|
||||
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
|
||||
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
+589
-57
@@ -1,24 +1,36 @@
|
||||
{
|
||||
"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",
|
||||
"version": "1.9.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 5, "h": 6 },
|
||||
"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."
|
||||
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||
},
|
||||
"Service timing": {
|
||||
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"label": "Application ID",
|
||||
"label": "Client ID",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
@@ -30,7 +42,7 @@
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "password",
|
||||
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
|
||||
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
@@ -38,27 +50,86 @@
|
||||
"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."
|
||||
"optionsRoute": "/service-types"
|
||||
},
|
||||
"planId": {
|
||||
"type": "select",
|
||||
"label": "Plan",
|
||||
"default": "",
|
||||
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||
"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."
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
"label": "Re-read the plan every N seconds",
|
||||
"label": "Refresh Interval (s)",
|
||||
"default": 30
|
||||
},
|
||||
"followProPresenter": {
|
||||
"type": "switch",
|
||||
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
|
||||
"label": "Follow ProPresenter Module",
|
||||
"default": true
|
||||
},
|
||||
"preRollMinutes": {
|
||||
"type": "number",
|
||||
"label": "Pre-roll (min)",
|
||||
"default": 30,
|
||||
"group": "Service timing",
|
||||
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
|
||||
},
|
||||
"useDocumentTriggers": {
|
||||
"type": "switch",
|
||||
"label": "ProPresenter document triggers",
|
||||
"default": false,
|
||||
"group": "Service timing"
|
||||
},
|
||||
"serviceStartTrigger": {
|
||||
"type": "select",
|
||||
"label": "Service start",
|
||||
"default": "schedule",
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"options": [
|
||||
{
|
||||
"value": "schedule",
|
||||
"label": "Scheduled service time"
|
||||
},
|
||||
{
|
||||
"value": "document",
|
||||
"label": "ProPresenter document"
|
||||
}
|
||||
]
|
||||
},
|
||||
"serviceStartDocument": {
|
||||
"type": "select",
|
||||
"label": "Start document",
|
||||
"default": "",
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"optionsRoute": "/documents",
|
||||
"help": "The document that goes live as the service begins."
|
||||
},
|
||||
"startGraceMinutes": {
|
||||
"type": "number",
|
||||
"label": "Start failsafe (min)",
|
||||
"default": 10,
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
|
||||
},
|
||||
"serviceEndDocument": {
|
||||
"type": "select",
|
||||
"label": "End document",
|
||||
"default": "",
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"optionsRoute": "/documents",
|
||||
"help": "The document that goes live as the service ends; it freezes the service clock."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
@@ -69,56 +140,517 @@
|
||||
"collapsed": false,
|
||||
"columns": 2
|
||||
},
|
||||
"Show / hide": {
|
||||
"Item notes": {
|
||||
"toggle": "showNotes"
|
||||
},
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 3,
|
||||
"help": "Untick anything this tile should not show."
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"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": ""
|
||||
"autoScroll": {
|
||||
"type": "boolean",
|
||||
"label": "Auto-scroll",
|
||||
"default": true
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "string",
|
||||
"type": "template",
|
||||
"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."
|
||||
"variables": [
|
||||
{
|
||||
"value": "{series}",
|
||||
"label": "Series"
|
||||
},
|
||||
{
|
||||
"value": "{part}",
|
||||
"label": "Part / plan title"
|
||||
},
|
||||
{
|
||||
"value": "{serviceType}",
|
||||
"label": "Service type"
|
||||
},
|
||||
{
|
||||
"value": "{date}",
|
||||
"label": "Date"
|
||||
},
|
||||
{
|
||||
"value": "{shortDate}",
|
||||
"label": "Short date"
|
||||
},
|
||||
{
|
||||
"value": "{time}",
|
||||
"label": "Service time"
|
||||
},
|
||||
{
|
||||
"value": "{length}",
|
||||
"label": "Planned length"
|
||||
},
|
||||
{
|
||||
"value": "{items}",
|
||||
"label": "Item count"
|
||||
}
|
||||
],
|
||||
"help": "Use • between items 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" }
|
||||
}
|
||||
"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": "Header fade",
|
||||
"default": true,
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintByType": {
|
||||
"type": "boolean",
|
||||
"label": "Tint by type",
|
||||
"default": false,
|
||||
"group": "Colors"
|
||||
},
|
||||
"showNotes": {
|
||||
"type": "switch",
|
||||
"label": "Item notes",
|
||||
"default": true,
|
||||
"group": "Item notes"
|
||||
},
|
||||
"noteCategories": {
|
||||
"type": "multiselect",
|
||||
"label": "Categories",
|
||||
"group": "Item notes",
|
||||
"optionsRoute": "/note-categories",
|
||||
"columns": 3,
|
||||
"allByDefault": true
|
||||
},
|
||||
"showTopBar": {
|
||||
"type": "boolean",
|
||||
"label": "Top bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSeries": {
|
||||
"type": "boolean",
|
||||
"label": "Series name",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPart": {
|
||||
"type": "boolean",
|
||||
"label": "Part / title",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceType": {
|
||||
"type": "boolean",
|
||||
"label": "Service type",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDate": {
|
||||
"type": "boolean",
|
||||
"label": "Plan date",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceTime": {
|
||||
"type": "boolean",
|
||||
"label": "Service time",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTotalLength": {
|
||||
"type": "boolean",
|
||||
"label": "Total length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showRunningClock": {
|
||||
"type": "boolean",
|
||||
"label": "Running clock",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showCountdown": {
|
||||
"type": "boolean",
|
||||
"label": "Countdown",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeaders": {
|
||||
"type": "boolean",
|
||||
"label": "Section headers",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemNumbers": {
|
||||
"type": "boolean",
|
||||
"label": "Item numbers",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStartTimes": {
|
||||
"type": "boolean",
|
||||
"label": "Start times",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLength": {
|
||||
"type": "boolean",
|
||||
"label": "Item length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemType": {
|
||||
"type": "boolean",
|
||||
"label": "Type badge",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showKey": {
|
||||
"type": "boolean",
|
||||
"label": "Song key",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showArrangement": {
|
||||
"type": "boolean",
|
||||
"label": "Arrangement",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSongAuthor": {
|
||||
"type": "boolean",
|
||||
"label": "Author / CCLI",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDescription": {
|
||||
"type": "boolean",
|
||||
"label": "Item description",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPreService": {
|
||||
"type": "boolean",
|
||||
"label": "Pre-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPostService": {
|
||||
"type": "boolean",
|
||||
"label": "Post-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNowIndicator": {
|
||||
"type": "boolean",
|
||||
"label": "Now indicator",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemRuntime": {
|
||||
"type": "boolean",
|
||||
"label": "Item runtime",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"dimCompleted": {
|
||||
"type": "boolean",
|
||||
"label": "Dim completed",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLiveFooter": {
|
||||
"type": "boolean",
|
||||
"label": "Match line",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"absProgress": {
|
||||
"type": "boolean",
|
||||
"label": "Song progress",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"absSections": {
|
||||
"type": "boolean",
|
||||
"label": "Song sections",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan",
|
||||
"description": "The order of service, item by item",
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 6
|
||||
},
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "teams",
|
||||
"name": "Teams",
|
||||
"description": "Who is scheduled, by team and position",
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 6
|
||||
},
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"instanceSchema": {
|
||||
"columns": {
|
||||
"type": "select",
|
||||
"label": "Columns",
|
||||
"default": "auto",
|
||||
"options": [
|
||||
{
|
||||
"value": "auto",
|
||||
"label": "Fit the tile"
|
||||
},
|
||||
{
|
||||
"value": "1",
|
||||
"label": "One"
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Two"
|
||||
},
|
||||
{
|
||||
"value": "3",
|
||||
"label": "Three"
|
||||
}
|
||||
]
|
||||
},
|
||||
"teams": {
|
||||
"type": "multiselect",
|
||||
"label": "Teams",
|
||||
"columns": 2,
|
||||
"allByDefault": true,
|
||||
"optionsRoute": "/teams"
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "template",
|
||||
"label": "Top bar text",
|
||||
"default": "{serviceType} • {date} | {people} on {teams}",
|
||||
"group": "Top bar",
|
||||
"variables": [
|
||||
{
|
||||
"value": "{series}",
|
||||
"label": "Series"
|
||||
},
|
||||
{
|
||||
"value": "{part}",
|
||||
"label": "Part / plan title"
|
||||
},
|
||||
{
|
||||
"value": "{serviceType}",
|
||||
"label": "Service type"
|
||||
},
|
||||
{
|
||||
"value": "{date}",
|
||||
"label": "Date"
|
||||
},
|
||||
{
|
||||
"value": "{shortDate}",
|
||||
"label": "Short date"
|
||||
},
|
||||
{
|
||||
"value": "{time}",
|
||||
"label": "Service time"
|
||||
},
|
||||
{
|
||||
"value": "{people}",
|
||||
"label": "People count"
|
||||
},
|
||||
{
|
||||
"value": "{teams}",
|
||||
"label": "Team count"
|
||||
},
|
||||
{
|
||||
"value": "{confirmed}",
|
||||
"label": "Confirmed count"
|
||||
}
|
||||
],
|
||||
"help": "Use • between items and | to start a second line."
|
||||
},
|
||||
"teamColor": {
|
||||
"type": "color",
|
||||
"label": "Team headers",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintTeams": {
|
||||
"type": "boolean",
|
||||
"label": "Tint teams",
|
||||
"default": false,
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintRows": {
|
||||
"type": "boolean",
|
||||
"label": "Tint rows too",
|
||||
"default": false,
|
||||
"group": "Colors",
|
||||
"showWhen": "tintTeams"
|
||||
},
|
||||
"headerGradient": {
|
||||
"type": "boolean",
|
||||
"label": "Header fade",
|
||||
"default": true,
|
||||
"group": "Colors"
|
||||
},
|
||||
"confirmedColor": {
|
||||
"type": "color",
|
||||
"label": "Confirmed",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"unconfirmedColor": {
|
||||
"type": "color",
|
||||
"label": "Unconfirmed",
|
||||
"default": "#f0b429",
|
||||
"group": "Colors"
|
||||
},
|
||||
"declinedColor": {
|
||||
"type": "color",
|
||||
"label": "Declined",
|
||||
"default": "#f4433c",
|
||||
"group": "Colors"
|
||||
},
|
||||
"showTopBar": {
|
||||
"type": "boolean",
|
||||
"label": "Top bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTeamHeaders": {
|
||||
"type": "boolean",
|
||||
"label": "Team headers",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTeamCounts": {
|
||||
"type": "boolean",
|
||||
"label": "Team counts",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Avatars",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPosition": {
|
||||
"type": "boolean",
|
||||
"label": "Positions",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"groupByPosition": {
|
||||
"type": "boolean",
|
||||
"label": "Group by position",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStatus": {
|
||||
"type": "boolean",
|
||||
"label": "Status",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNotes": {
|
||||
"type": "boolean",
|
||||
"label": "Person notes",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDeclined": {
|
||||
"type": "boolean",
|
||||
"label": "Declined people",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showUnconfirmed": {
|
||||
"type": "boolean",
|
||||
"label": "Unconfirmed people",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showEmptyTeams": {
|
||||
"type": "boolean",
|
||||
"label": "Teams with nobody",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Top bar": {
|
||||
"collapsed": false
|
||||
},
|
||||
"Colors": {
|
||||
"collapsed": false,
|
||||
"columns": 2
|
||||
},
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "key",
|
||||
"name": "Song Key",
|
||||
"description": "The key of the item on screen, big",
|
||||
"defaultSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"minSize": {
|
||||
"w": 1,
|
||||
"h": 1
|
||||
},
|
||||
"instanceSchema": {
|
||||
"keyColor": {
|
||||
"type": "color",
|
||||
"label": "Key",
|
||||
"default": "#4ea1ff"
|
||||
},
|
||||
"showSongName": {
|
||||
"type": "boolean",
|
||||
"label": "Song name",
|
||||
"default": true
|
||||
},
|
||||
"showNextKey": {
|
||||
"type": "boolean",
|
||||
"label": "Next key",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1268
-144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service timing for the PCO Plan module — pure functions, no I/O, no
|
||||
* timers. server.js feeds them the plan's service times, the documents
|
||||
* ProPresenter puts live and the clock; service-timing.test.js calls them
|
||||
* directly. Two questions are answered here:
|
||||
*
|
||||
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
|
||||
* 2. When does that service count as started, and as ended? → advance
|
||||
*
|
||||
* ── Service windows ────────────────────────────────────────────────────
|
||||
*
|
||||
* A plan can have several services (plan_times of type "service": 9:00 and
|
||||
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
|
||||
* timing and the countdown at any moment, and moving into the next
|
||||
* service's window resets all three. Each service's window runs from the
|
||||
* end of the previous one to a boundary the next service's pre-roll sets:
|
||||
*
|
||||
* boundary(i → i+1) = min( start[i+1],
|
||||
* max( start[i+1] − preRoll, endOf[i] ) )
|
||||
*
|
||||
* endOf[i] = the moment service i ended (its end cue fired), else its
|
||||
* scheduled end (plan_time ends_at, else start + the planned
|
||||
* length of the "during" items, else 90 min).
|
||||
*
|
||||
* In words: the next service takes over `preRoll` before its scheduled
|
||||
* start (30 min by default; admin "Pre-roll"), but never before the current
|
||||
* one has ended or was due to end, and never later than its own start. A
|
||||
* service still running at the boundary is stopped there — that is the
|
||||
* "next service's start" failsafe the end cue has. The first window opens at
|
||||
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
|
||||
* countdown running, service idle); the last window never closes — the last
|
||||
* service stays "stopped" until the plan itself changes (the next day's plan
|
||||
* takes over through the plan picker). The last service also has a failsafe
|
||||
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||
*
|
||||
* ── Start and end of a service ─────────────────────────────────────────
|
||||
*
|
||||
* phase: idle → running → stopped, never backwards within one window.
|
||||
*
|
||||
* start, schedule mode (default) at the scheduled start; startedAt = it.
|
||||
* start, document mode when the start document goes live
|
||||
* (edge: it has to *become* live, from
|
||||
* pre-roll before the scheduled start
|
||||
* onwards — a rehearsal an hour earlier
|
||||
* that shows the same document is not the
|
||||
* service). Failsafe: if the document has
|
||||
* not gone live by start + grace (admin
|
||||
* "Start failsafe", 10 min default), the
|
||||
* service starts anyway, counting from
|
||||
* the scheduled start — the best estimate
|
||||
* of when it really began.
|
||||
* end, document when the end document goes live while
|
||||
* the service is running (an end document
|
||||
* live before the start — the previous
|
||||
* service's loop — is ignored). Failsafe
|
||||
* either way: the window boundary (see
|
||||
* above), or, for the last service, its
|
||||
* scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||
*
|
||||
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
|
||||
* matching.js for the tolerance), so "Post-Service Loop" still ends the
|
||||
* service when ProPresenter calls it "Post Service Loop".
|
||||
*/
|
||||
|
||||
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
|
||||
|
||||
const MINUTE = 60000;
|
||||
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
|
||||
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
|
||||
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
|
||||
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
|
||||
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
|
||||
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
|
||||
/** A service with no end time and no planned item lengths is assumed this long. */
|
||||
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
|
||||
|
||||
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
|
||||
function serviceTimes(planTimes) {
|
||||
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
|
||||
const services = list.filter((t) => t.timeType === 'service');
|
||||
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window each service owns (see the file comment).
|
||||
*
|
||||
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
|
||||
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
|
||||
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
|
||||
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
|
||||
*/
|
||||
function serviceWindows(planTimes, opts = {}) {
|
||||
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
|
||||
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
|
||||
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
|
||||
const services = serviceTimes(planTimes);
|
||||
const windows = services.map((svc, i) => {
|
||||
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
|
||||
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
|
||||
const next = services[i + 1];
|
||||
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
|
||||
return {
|
||||
id: String(svc.id),
|
||||
name: String(svc.name || ''),
|
||||
index: i,
|
||||
count: services.length,
|
||||
startsAt: svc.startsAt,
|
||||
endsAt: scheduledEnd,
|
||||
windowFrom: -Infinity, // filled in below
|
||||
windowUntil,
|
||||
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
|
||||
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
|
||||
isLast: !next,
|
||||
};
|
||||
});
|
||||
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** The window that holds `now` — null only when there are no services. */
|
||||
function selectService(windows, now) {
|
||||
if (!Array.isArray(windows) || !windows.length) return null;
|
||||
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
|
||||
}
|
||||
|
||||
/** Timing state for a service that has just come into its window. */
|
||||
function freshService(window) {
|
||||
return {
|
||||
id: window ? String(window.id) : '',
|
||||
phase: window ? 'idle' : 'none',
|
||||
startedAt: 0,
|
||||
endedAt: 0,
|
||||
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
|
||||
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
|
||||
cue: '', // the document that started / ended it
|
||||
};
|
||||
}
|
||||
|
||||
function fmtTime(ms) {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a service's timing on by a clock tick or by a document going live.
|
||||
* Pure: returns a new state and a one-line description of what happened
|
||||
* (for the log), or `changed: false`.
|
||||
*
|
||||
* @param {object} svc state from freshService(window) / a previous advance()
|
||||
* @param {object} window from serviceWindows()
|
||||
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
|
||||
* @param {number} now ms epoch
|
||||
* @param {string} [cue] the document that just went live (omit on a plain tick)
|
||||
* @returns {{ svc: object, changed: boolean, event: string }}
|
||||
*/
|
||||
function advance(svc, window, opts, now, cue) {
|
||||
const o = opts || {};
|
||||
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
|
||||
const cueName = String(cue || '').trim();
|
||||
const same = { svc, changed: false, event: '' };
|
||||
if (!svc || !window || svc.id !== String(window.id)) return same;
|
||||
const at = fmtTime(window.startsAt);
|
||||
|
||||
if (svc.phase === 'idle') {
|
||||
const documentMode = o.startMode === 'document' && o.startDocument;
|
||||
if (documentMode) {
|
||||
if (cueName && now >= window.cueFrom) {
|
||||
const score = similarity(cueName, o.startDocument);
|
||||
if (score >= TRIGGER_THRESHOLD) {
|
||||
return {
|
||||
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
|
||||
changed: true,
|
||||
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (now >= window.startsAt + grace) {
|
||||
return {
|
||||
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
|
||||
changed: true,
|
||||
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
|
||||
};
|
||||
}
|
||||
return same;
|
||||
}
|
||||
if (now >= window.startsAt) {
|
||||
return {
|
||||
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
|
||||
changed: true,
|
||||
event: `started — scheduled ${at}`,
|
||||
};
|
||||
}
|
||||
return same;
|
||||
}
|
||||
|
||||
if (svc.phase === 'running') {
|
||||
if (o.endDocument && cueName) {
|
||||
const score = similarity(cueName, o.endDocument);
|
||||
if (score >= TRIGGER_THRESHOLD) {
|
||||
return {
|
||||
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
|
||||
changed: true,
|
||||
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (now >= window.failsafeEndAt) {
|
||||
const endedAt = Math.min(now, window.failsafeEndAt);
|
||||
return {
|
||||
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
|
||||
changed: true,
|
||||
event: window.isLast
|
||||
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
|
||||
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
|
||||
};
|
||||
}
|
||||
return same;
|
||||
}
|
||||
|
||||
return same;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MINUTE,
|
||||
DEFAULT_PRE_ROLL_MS,
|
||||
DEFAULT_START_GRACE_MS,
|
||||
LAST_SERVICE_OVERRUN_MS,
|
||||
DEFAULT_SERVICE_LENGTH_MS,
|
||||
serviceTimes,
|
||||
serviceWindows,
|
||||
selectService,
|
||||
freshService,
|
||||
advance,
|
||||
fmtTime,
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the PCO Plan module's pure service-timing logic.
|
||||
*
|
||||
* node modules/pco-plan/service-timing.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
|
||||
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
|
||||
* pre-roll) so the expectations read like the day would.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
LAST_SERVICE_OVERRUN_MS,
|
||||
serviceWindows,
|
||||
selectService,
|
||||
freshService,
|
||||
advance,
|
||||
} = require('./service-timing');
|
||||
|
||||
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
|
||||
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
|
||||
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
const PLAN_TIMES = [
|
||||
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
|
||||
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
|
||||
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
|
||||
];
|
||||
const PRE_ROLL = 30 * MINUTE;
|
||||
const OPTS = { preRollMs: PRE_ROLL };
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(` FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('service windows');
|
||||
|
||||
test('rehearsal is not a service; windows are contiguous and ordered', () => {
|
||||
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
|
||||
assert.strictEqual(w[0].windowFrom, -Infinity);
|
||||
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
|
||||
assert.strictEqual(w[1].windowUntil, Infinity);
|
||||
assert.strictEqual(w[0].count, 2);
|
||||
});
|
||||
|
||||
test('the boundary is 30 min before the next start once the first was due to end', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
|
||||
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
|
||||
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
|
||||
});
|
||||
|
||||
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
|
||||
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
|
||||
const [tight] = serviceWindows(
|
||||
[
|
||||
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
|
||||
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
|
||||
],
|
||||
OPTS
|
||||
);
|
||||
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
|
||||
});
|
||||
|
||||
test('an early actual end lets the next window open at pre-roll', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
|
||||
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
|
||||
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
|
||||
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
|
||||
});
|
||||
|
||||
test('a service without ends_at gets the planned length, else 90 min', () => {
|
||||
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
|
||||
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
|
||||
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
|
||||
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
|
||||
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
|
||||
});
|
||||
|
||||
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
|
||||
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
|
||||
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
|
||||
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
|
||||
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
|
||||
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
|
||||
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
|
||||
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
|
||||
assert.strictEqual(selectService([], at(9, 0)), null);
|
||||
});
|
||||
|
||||
console.log('service start');
|
||||
|
||||
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
|
||||
const DOCUMENT = {
|
||||
startMode: 'document',
|
||||
startDocument: 'Welcome & Announcements',
|
||||
endDocument: 'Post Service Loop',
|
||||
startGraceMs: 10 * MINUTE,
|
||||
};
|
||||
|
||||
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
let s = freshService(w1);
|
||||
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
|
||||
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
|
||||
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
|
||||
assert.strictEqual(out.changed, true);
|
||||
s = out.svc;
|
||||
assert.strictEqual(s.phase, 'running');
|
||||
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
|
||||
assert.strictEqual(s.startTrigger, 'schedule');
|
||||
});
|
||||
|
||||
test('document mode: the start document going live starts the service at that moment', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s = freshService(w1);
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
|
||||
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
|
||||
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
|
||||
assert.strictEqual(out.svc.phase, 'running');
|
||||
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
|
||||
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
|
||||
});
|
||||
|
||||
test('document mode: the start document is only a cue from pre-roll before the start', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s = freshService(w1);
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
|
||||
});
|
||||
|
||||
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s = freshService(w1);
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
|
||||
const out = advance(s, w1, DOCUMENT, at(9, 10));
|
||||
assert.strictEqual(out.changed, true);
|
||||
assert.strictEqual(out.svc.phase, 'running');
|
||||
assert.strictEqual(out.svc.startedAt, at(9, 0));
|
||||
assert.strictEqual(out.svc.startTrigger, 'failsafe');
|
||||
assert.match(out.event, /failsafe/);
|
||||
});
|
||||
|
||||
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
|
||||
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||
assert.strictEqual(out.svc.startedAt, at(9, 12));
|
||||
});
|
||||
|
||||
console.log('service end');
|
||||
|
||||
test('the end document freezes a running service; a second start document is ignored', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
|
||||
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
|
||||
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
|
||||
s = out.svc;
|
||||
assert.strictEqual(s.phase, 'stopped');
|
||||
assert.strictEqual(s.endedAt, at(10, 12));
|
||||
assert.strictEqual(s.endTrigger, 'document');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
|
||||
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
|
||||
});
|
||||
|
||||
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
|
||||
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
|
||||
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
|
||||
const out = advance(s, w1, SCHEDULE, at(10, 31));
|
||||
assert.strictEqual(out.changed, true);
|
||||
assert.strictEqual(out.svc.phase, 'stopped');
|
||||
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
|
||||
assert.strictEqual(out.svc.endTrigger, 'next-service');
|
||||
});
|
||||
|
||||
test('failsafe: the last service stops an hour after its scheduled end', () => {
|
||||
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
|
||||
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
|
||||
const out = advance(s, w2, SCHEDULE, at(13, 15));
|
||||
assert.strictEqual(out.changed, true);
|
||||
assert.strictEqual(out.svc.phase, 'stopped');
|
||||
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
|
||||
assert.strictEqual(out.svc.endTrigger, 'plan-end');
|
||||
});
|
||||
|
||||
test('the end document does not stop a service in the wrong window', () => {
|
||||
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
|
||||
});
|
||||
|
||||
console.log('matching');
|
||||
|
||||
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
|
||||
assert.strictEqual(MATCH_THRESHOLD, 0.6);
|
||||
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
|
||||
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
|
||||
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
|
||||
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
|
||||
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
|
||||
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
|
||||
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
|
||||
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
|
||||
assert.strictEqual(isCue('Anything', ''), false);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
+380
-1
@@ -86,6 +86,7 @@
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
@@ -205,7 +206,9 @@
|
||||
|
||||
[data-module="pco-plan"] .pp-now-tag {
|
||||
display: none;
|
||||
padding: 0.05em 0.45em;
|
||||
/* Optically centred like the section chips: uppercase has no descenders,
|
||||
so the capitals sat 0.59px low in the pill. */
|
||||
padding: 0.018em 0.45em 0.082em;
|
||||
border-radius: 999px;
|
||||
background: var(--pp-current);
|
||||
color: var(--bg);
|
||||
@@ -365,3 +368,379 @@
|
||||
[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); }
|
||||
|
||||
/* ── the Teams tile ───────────────────────────────────────────────────
|
||||
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
|
||||
colours come from the tile's own settings (--tm-team per section,
|
||||
--tm-status per person) over the shell's palette. */
|
||||
|
||||
[data-module="pco-plan"] .tm-wrap {
|
||||
--pp-size: 14px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--pp-size);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-head {
|
||||
flex: none;
|
||||
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"] .tm-head[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .tm-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-sub {
|
||||
margin-top: 0.15em;
|
||||
font-size: 0.82em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .tm-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 0.6em;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* ── a team ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
|
||||
|
||||
/* The header band runs edge to edge, like the Plan tile's section headers. */
|
||||
[data-module="pco-plan"] .tm-team-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
padding: 0.45em 0.9em 0.4em;
|
||||
margin-top: 0.7em;
|
||||
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
|
||||
[data-module="pco-plan"] .header-gradient .tm-team-head {
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--tm-team) 30%, transparent),
|
||||
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
|
||||
transparent);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-team-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-team-count {
|
||||
flex: none;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-people {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
|
||||
gap: 0.15em 0.5em;
|
||||
padding: 0.35em 0.6em 0.2em;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-people.is-fixed-cols {
|
||||
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
|
||||
}
|
||||
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
|
||||
|
||||
/* Grouped by position: headings span, each position's people form their own grid. */
|
||||
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
|
||||
|
||||
[data-module="pco-plan"] .tm-position-head {
|
||||
padding: 0.4em 0.9em 0;
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-team-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0.2em 0.3em 0.4em;
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── one person ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
min-width: 0;
|
||||
padding: 0.3em 0.4em;
|
||||
border-radius: 0.45em;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
|
||||
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
|
||||
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
|
||||
|
||||
[data-module="pco-plan"] .tm-avatar {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 2.1em;
|
||||
height: 2.1em;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
|
||||
color: var(--text);
|
||||
font-size: 0.78em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
/* The photo covers the initials; if it fails to load the script drops it. */
|
||||
[data-module="pco-plan"] .tm-avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
|
||||
|
||||
[data-module="pco-plan"] .tm-name {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
|
||||
|
||||
[data-module="pco-plan"] .tm-position {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-note {
|
||||
margin-top: 0.1em;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.3;
|
||||
color: color-mix(in srgb, var(--text) 75%, var(--muted));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-status {
|
||||
flex: none;
|
||||
width: 0.55em;
|
||||
height: 0.55em;
|
||||
border-radius: 999px;
|
||||
background: var(--tm-status, var(--muted));
|
||||
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ── empty / degraded ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-empty {
|
||||
padding: 12% 1.2em 1em;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
|
||||
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
|
||||
[data-module="pco-plan"] .tm-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"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
||||
|
||||
|
||||
/* ── AbleSet under the current item ───────────────────────────────────
|
||||
Drawn only while an AbleSet module is configured, reachable, and playing
|
||||
the song this item is. The fill is a layer behind the row's own content;
|
||||
everything else in the row is lifted over it. */
|
||||
|
||||
[data-module="pco-plan"] .pp-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 0;
|
||||
border-radius: 0.5em 0 0 0.5em;
|
||||
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
|
||||
/* Packets arrive about five times a second; the glide covers the gaps. */
|
||||
transition: width 0.22s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
|
||||
|
||||
/* The sections strip, bottom right of the current item. */
|
||||
[data-module="pco-plan"] .pp-abs-sections {
|
||||
display: flex;
|
||||
gap: 0.3em;
|
||||
/* Bottom right of the item: hug the content and sit against the right edge
|
||||
until there are more sections than fit, when it fills the width and
|
||||
scrolls instead. */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 0.35em;
|
||||
margin-left: auto;
|
||||
padding-bottom: 0.1em;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pp-abs-sec {
|
||||
flex: none;
|
||||
/* Uppercase text has no descenders, so the font's descent allowance below
|
||||
the baseline leaves the caps sitting high in the box — measured at 0.9px
|
||||
too much headroom. The padding is a touch shallower on top and deeper
|
||||
below to centre the letters themselves; in em, so it holds at any text
|
||||
size. */
|
||||
padding: 0.095em 0.55em 0.185em;
|
||||
/* The same rounded square, tinted ground and transparent border the AbleSet
|
||||
module gives its own section pills, so the two read as one family. */
|
||||
border-radius: 0.35em;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in srgb, var(--text) 8%, var(--panel));
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
||||
|
||||
/* The section now playing fills left to right as it goes, like the row. */
|
||||
/* The section playing keeps its own ground and fills across it, so the
|
||||
progress reads against the chip rather than against the row behind it. */
|
||||
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
||||
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
|
||||
}
|
||||
|
||||
/* ── the Song Key tile ────────────────────────────────────────────────
|
||||
One value, as large as the tile allows. Sizes come from the tile itself
|
||||
(container units), like the AbleSet module's single readouts, so a small
|
||||
tile and a wall-sized one both read properly. */
|
||||
|
||||
[data-module="pco-plan"] .pk-wrap {
|
||||
--pk-color: #4ea1ff;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2em;
|
||||
padding: 4%;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pk-key {
|
||||
font-size: clamp(18px, 46cqmin, 260px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
|
||||
|
||||
/* A key is a note, but it has to read as one word at a glance from the back
|
||||
of a room: the accidental and the quality sit on the same line as the
|
||||
letter, only smaller. Raising the accidental looked like two stacked
|
||||
characters at tile sizes. */
|
||||
[data-module="pco-plan"] .pk-accidental {
|
||||
font-size: 0.68em;
|
||||
margin-left: 0.01em;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-quality {
|
||||
font-size: 0.5em;
|
||||
font-weight: 700;
|
||||
margin-left: 0.05em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pk-name {
|
||||
max-width: 100%;
|
||||
font-size: clamp(9px, 9cqmin, 30px);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pk-next {
|
||||
max-width: 100%;
|
||||
font-size: clamp(8px, 6.5cqmin, 20px);
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
|
||||
/* The feed that picks the item is gone: the key stays readable, but faded,
|
||||
so nobody trusts it as current without looking at the status dot. */
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/* PCO Plan — the Teams tile.
|
||||
|
||||
Who is scheduled on the current plan, by team and position, from the same
|
||||
server state the Plan tile draws (state.teams). Everything shown is this
|
||||
tile's own choice (gear menu): which teams and positions, what each row
|
||||
carries, and the colours. Photos come from ProdDash's own avatar route —
|
||||
the browser never talks to Planning Center. */
|
||||
|
||||
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>';
|
||||
|
||||
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
|
||||
|
||||
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
||||
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
||||
|
||||
export default function createTeams({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
let lastKey = '';
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="tm-wrap">
|
||||
<header class="tm-head">
|
||||
<div class="tm-title"></div>
|
||||
<div class="tm-sub"></div>
|
||||
</header>
|
||||
<div class="tm-body"></div>
|
||||
</div>`;
|
||||
|
||||
const wrap = root.querySelector('.tm-wrap');
|
||||
const headEl = root.querySelector('.tm-head');
|
||||
const titleEl = root.querySelector('.tm-title');
|
||||
const subEl = root.querySelector('.tm-sub');
|
||||
const bodyEl = root.querySelector('.tm-body');
|
||||
|
||||
const cfg = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||
|
||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||
|
||||
const refreshBtn = moduleApi.header.addButton({
|
||||
icon: REFRESH_SVG,
|
||||
title: 'Re-read from Planning Center',
|
||||
onClick() {
|
||||
refreshBtn.classList.add('active');
|
||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||
.catch(() => { /* the stream reports the outcome */ })
|
||||
.finally(() => refreshBtn.classList.remove('active'));
|
||||
},
|
||||
});
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────── */
|
||||
|
||||
function colorOf(value, fallback) {
|
||||
const s = String(value || '').trim();
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's colour when "Tint teams" is on. Taken from the team's position
|
||||
* in the plan, not a hash of its name: neighbouring teams then always look
|
||||
* different, which is the whole point of tinting them.
|
||||
*/
|
||||
function teamColor(index) {
|
||||
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
|
||||
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
|
||||
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
|
||||
return colorOf(cfg().unconfirmedColor, '#f0b429');
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
|
||||
|
||||
/** "Sarah Miles" → "SM"; one word → its first two letters. */
|
||||
function initialsOf(name) {
|
||||
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return '?';
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** A saved multiselect list, or null when the tile has never chosen (= all). */
|
||||
function chosen(key) {
|
||||
const v = cfg()[key];
|
||||
return Array.isArray(v) ? v : null;
|
||||
}
|
||||
|
||||
/* ── which teams and people to show ─────────────────────────────── */
|
||||
|
||||
function visibleTeams() {
|
||||
const teams = Array.isArray(state?.teams) ? state.teams : [];
|
||||
const wantTeams = chosen('teams');
|
||||
const out = [];
|
||||
for (const team of teams) {
|
||||
if (wantTeams && !wantTeams.includes(team.id)) continue;
|
||||
const members = team.members.filter((m) => {
|
||||
if (m.status === 'declined' && !on('showDeclined')) return false;
|
||||
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
|
||||
return true;
|
||||
});
|
||||
if (!members.length && !cfg().showEmptyTeams) continue;
|
||||
out.push({ ...team, members });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 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…');
|
||||
}
|
||||
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
|
||||
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
|
||||
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
/* ── top bar ────────────────────────────────────────────────────── */
|
||||
|
||||
/** The counts describe what this tile shows, not what the plan holds. */
|
||||
function templateValues() {
|
||||
const plan = state?.plan || {};
|
||||
const teams = visibleTeams();
|
||||
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
||||
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
||||
return {
|
||||
series: plan.seriesTitle || '',
|
||||
part: plan.title || '',
|
||||
servicetype: state?.serviceType?.name || '',
|
||||
date: plan.dates || '',
|
||||
shortdate: plan.shortDates || plan.dates || '',
|
||||
time: plan.serviceStartsAt
|
||||
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
: '',
|
||||
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
|
||||
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
|
||||
confirmed: confirmed ? `${confirmed} confirmed` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
|
||||
* a line • separates segments and a segment whose placeholders all came out
|
||||
* empty is dropped, so a plan with no series never leaves a stray bullet.
|
||||
*/
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
|
||||
const kept = [];
|
||||
for (const seg of line.split(/\s*[•·]\s*/)) {
|
||||
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 show = on('showTopBar') && state?.plan;
|
||||
headEl.hidden = !show;
|
||||
if (!show) return;
|
||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
|
||||
subEl.textContent = line1 ? line2 || '' : '';
|
||||
subEl.hidden = !subEl.textContent;
|
||||
}
|
||||
|
||||
/* ── 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 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.`,
|
||||
};
|
||||
}
|
||||
if (!(state.teams || []).length) {
|
||||
return {
|
||||
title: 'Nobody scheduled yet',
|
||||
hint: state.teamsError || 'This plan has no team members in Planning Center.',
|
||||
};
|
||||
}
|
||||
if (!visibleTeams().length) {
|
||||
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMember(member) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `tm-person is-${member.status}`;
|
||||
|
||||
if (on('showAvatars')) {
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'tm-avatar';
|
||||
avatar.textContent = initialsOf(member.name);
|
||||
if (member.hasPhoto) {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.alt = '';
|
||||
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
|
||||
// No photo, or the CDN is unreachable: the initials underneath stand.
|
||||
img.addEventListener('error', () => img.remove());
|
||||
avatar.appendChild(img);
|
||||
}
|
||||
row.appendChild(avatar);
|
||||
}
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'tm-person-main';
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'tm-name';
|
||||
nameEl.textContent = member.name;
|
||||
main.appendChild(nameEl);
|
||||
|
||||
if (on('showPosition') && member.position && !cfg().groupByPosition) {
|
||||
const pos = document.createElement('div');
|
||||
pos.className = 'tm-position';
|
||||
pos.textContent = member.position;
|
||||
main.appendChild(pos);
|
||||
}
|
||||
if (cfg().showNotes && (member.notes || member.declineReason)) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'tm-note';
|
||||
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
|
||||
main.appendChild(note);
|
||||
}
|
||||
row.appendChild(main);
|
||||
|
||||
if (on('showStatus')) {
|
||||
const status = document.createElement('div');
|
||||
status.className = 'tm-status';
|
||||
status.style.setProperty('--tm-status', statusColor(member.status));
|
||||
status.title = STATUS_LABEL[member.status] || member.status;
|
||||
row.appendChild(status);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** One team: its header band, then its people (optionally under position sub-headings). */
|
||||
function buildTeam(team, index) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'tm-team';
|
||||
section.style.setProperty('--tm-team', teamColor(index));
|
||||
|
||||
if (on('showTeamHeaders')) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tm-team-head';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tm-team-name';
|
||||
name.textContent = team.name;
|
||||
header.appendChild(name);
|
||||
if (on('showTeamCounts')) {
|
||||
const counts = document.createElement('span');
|
||||
counts.className = 'tm-team-count';
|
||||
const pending = team.counts.unconfirmed;
|
||||
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
|
||||
header.appendChild(counts);
|
||||
}
|
||||
section.appendChild(header);
|
||||
}
|
||||
|
||||
const columns = String(cfg().columns || 'auto');
|
||||
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
|
||||
// Grouped, the container is a plain block so each position heading spans
|
||||
// the width and only its own people form a grid.
|
||||
const list = document.createElement('div');
|
||||
list.className = grouping ? 'tm-groups' : 'tm-people';
|
||||
if (!grouping && columns !== 'auto') {
|
||||
list.style.setProperty('--tm-cols', columns);
|
||||
list.classList.add('is-fixed-cols');
|
||||
}
|
||||
|
||||
if (grouping) {
|
||||
// Positions keep the order the members already sort in.
|
||||
const groups = new Map();
|
||||
for (const m of team.members) {
|
||||
const key = m.position || '(no position)';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(m);
|
||||
}
|
||||
for (const [position, members] of groups) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'tm-position-head';
|
||||
label.textContent = position;
|
||||
list.appendChild(label);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'tm-people is-sub';
|
||||
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
|
||||
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
|
||||
for (const m of members) sub.appendChild(buildMember(m));
|
||||
list.appendChild(sub);
|
||||
}
|
||||
} else {
|
||||
for (const m of team.members) list.appendChild(buildMember(m));
|
||||
}
|
||||
|
||||
if (!team.members.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'tm-team-empty';
|
||||
none.textContent = 'Nobody scheduled';
|
||||
list.appendChild(none);
|
||||
}
|
||||
section.appendChild(list);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const empty = emptyMessage();
|
||||
if (empty) {
|
||||
lastKey = '';
|
||||
bodyEl.innerHTML = '';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'tm-empty';
|
||||
const t = document.createElement('div');
|
||||
t.className = 'tm-empty-title';
|
||||
t.textContent = empty.title;
|
||||
box.appendChild(t);
|
||||
if (empty.hint) {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'tm-empty-hint';
|
||||
h.textContent = empty.hint;
|
||||
box.appendChild(h);
|
||||
}
|
||||
if (empty.link) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'tm-empty-link';
|
||||
a.href = empty.link.href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = empty.link.text;
|
||||
box.appendChild(a);
|
||||
}
|
||||
bodyEl.appendChild(box);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild only when the roster or the settings actually changed: the
|
||||
// avatars are <img>s and a needless rebuild makes them flicker.
|
||||
const teams = visibleTeams();
|
||||
const key = JSON.stringify([teams, cfg()]);
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
bodyEl.innerHTML = '';
|
||||
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
|
||||
}
|
||||
|
||||
function render() {
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
||||
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
||||
renderStatus();
|
||||
renderHead();
|
||||
renderBody();
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
function accept(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
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 */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
return {
|
||||
start() {
|
||||
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();
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onConfigChange() {
|
||||
// Admin holds the connection only; the server re-inits and re-sends.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,13 @@
|
||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||
contract: everything lives inside the tile's root, per-tile state
|
||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||
persists in instance settings, and all network traffic goes through
|
||||
moduleApi (the module's server proxy at /prodcom/*). */
|
||||
persists in instance settings, and the data comes from the module's own
|
||||
server, which keeps one connection to ProdCom for every dashboard: a
|
||||
snapshot on connect (`state`), then each changed entry (`entry`). */
|
||||
|
||||
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* ── per-instance state ─────────────────────────────────────────── */
|
||||
@@ -15,7 +20,6 @@ export default function create({ root, moduleApi }) {
|
||||
let pinnedToLatest = true;
|
||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||
let stream = null; // moduleApi.sse handle
|
||||
let bootTimer = null;
|
||||
let stopped = false;
|
||||
|
||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||
@@ -48,7 +52,7 @@ export default function create({ root, moduleApi }) {
|
||||
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||
const channelsMenu = moduleApi.header.addMenu({
|
||||
icon: CHANNELS_SVG,
|
||||
title: 'Choose visible channels',
|
||||
title: 'Channels',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
channelMenuEl = menu;
|
||||
@@ -65,7 +69,7 @@ export default function create({ root, moduleApi }) {
|
||||
let timeBtn = null;
|
||||
timeBtn = moduleApi.header.addButton({
|
||||
icon: CLOCK_SVG,
|
||||
title: 'Show / hide timestamps',
|
||||
title: 'Timestamps',
|
||||
onClick() {
|
||||
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
||||
applyInstanceSettings();
|
||||
@@ -77,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||
applyInstanceSettings();
|
||||
}
|
||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
|
||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||
const flowBtn = moduleApi.header.addButton({
|
||||
@@ -91,9 +95,9 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
const latestBtn = moduleApi.header.addButton({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Jump to latest',
|
||||
title: 'Latest',
|
||||
onClick() {
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
@@ -103,11 +107,87 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
const clearBtn = moduleApi.header.addButton({
|
||||
icon: CLEAR_SVG,
|
||||
title: 'Clear this tile (does not affect ProdCom)',
|
||||
title: 'Clear',
|
||||
onClick: clearView,
|
||||
});
|
||||
clearBtn.classList.add('pt-clear');
|
||||
|
||||
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||
fullscreen fit, that used to take the channel filter with it. Below
|
||||
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||
the same actions, so the filter button is the last thing to go. */
|
||||
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||
|
||||
let moreMenuEl = null;
|
||||
const moreMenu = moduleApi.header.addMenu({
|
||||
icon: MORE_SVG,
|
||||
title: 'More',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
moreMenuEl = menu;
|
||||
rebuildMoreMenu();
|
||||
},
|
||||
});
|
||||
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||
|
||||
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||
if (on !== null) {
|
||||
item.setAttribute('role', 'menuitemcheckbox');
|
||||
item.setAttribute('aria-checked', String(on));
|
||||
} else {
|
||||
item.setAttribute('role', 'menuitem');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'check';
|
||||
check.textContent = '✓';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = label;
|
||||
item.append(check, name);
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||
onClick();
|
||||
});
|
||||
moreMenuEl.appendChild(item);
|
||||
}
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
if (!moreMenuEl) return;
|
||||
moreMenuEl.innerHTML = '';
|
||||
const s = moduleApi.instanceSettings;
|
||||
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'pt-menu-divider';
|
||||
moreMenuEl.appendChild(divider);
|
||||
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||
}
|
||||
|
||||
let compact = null; // unknown until the first measurement
|
||||
function applyCompact(width) {
|
||||
const next = width > 0 && width < COMPACT_PX;
|
||||
if (next === compact) return;
|
||||
compact = next;
|
||||
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||
moreWrap.hidden = !compact;
|
||||
if (!compact) moreMenu.close();
|
||||
}
|
||||
// the tile's own body tells the width; onResize only knows grid cells
|
||||
const sizeWatch = new ResizeObserver((entries) => {
|
||||
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||
});
|
||||
sizeWatch.observe(root);
|
||||
applyCompact(root.clientWidth);
|
||||
|
||||
function applyInstanceSettings() {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
@@ -120,10 +200,9 @@ export default function create({ root, moduleApi }) {
|
||||
wrap.classList.toggle('newest-first', up);
|
||||
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
||||
flowBtn.classList.toggle('active', up);
|
||||
flowBtn.title = up
|
||||
? 'Newest at top — click for newest at bottom'
|
||||
: 'Newest at bottom — click for newest at top';
|
||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||
}
|
||||
applyInstanceSettings();
|
||||
|
||||
@@ -174,9 +253,7 @@ export default function create({ root, moduleApi }) {
|
||||
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||||
channelCountEl.hidden = !filtering;
|
||||
channelsMenu.button.classList.toggle('active', filtering);
|
||||
channelsMenu.button.title = filtering
|
||||
? `Channels — showing ${visibleCount} of ${channels.size}`
|
||||
: 'Choose visible channels';
|
||||
channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
|
||||
}
|
||||
|
||||
function rebuildChannelMenu() {
|
||||
@@ -353,120 +430,69 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* The stream sends JSON objects for new/updated/completed transcript
|
||||
entries. Be liberal about the envelope shape. */
|
||||
function extractEntries(obj) {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
||||
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
||||
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
||||
if (obj[key] && typeof obj[key] === 'object') {
|
||||
const found = extractEntries(obj[key]);
|
||||
if (found.length) return found;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
/* ── the module's feed: one snapshot, then each change ─────────── */
|
||||
|
||||
function applyStatus(st) {
|
||||
if (!st || typeof st !== 'object') return;
|
||||
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
|
||||
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
||||
}
|
||||
|
||||
function handleEventData(raw) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(raw); } catch { return; }
|
||||
const kind = String(obj.type || obj.event || '').toLowerCase();
|
||||
if (kind.includes('clear')) {
|
||||
entryEls.forEach((el) => el.remove());
|
||||
entryEls.clear();
|
||||
emptyEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
extractEntries(obj).forEach(upsertEntry);
|
||||
}
|
||||
|
||||
/* ── data loading (all via the module's proxy) ──────────────────── */
|
||||
|
||||
async function loadChannels() {
|
||||
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
|
||||
if (!res.ok) throw new Error('channels ' + res.status);
|
||||
const body = await res.json();
|
||||
function applySnapshot(snap) {
|
||||
if (!snap || typeof snap !== 'object') return;
|
||||
channels.clear();
|
||||
for (const ch of body.data || []) {
|
||||
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
|
||||
}
|
||||
rebuildChannelMenu();
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
|
||||
if (!res.ok) throw new Error('groups ' + res.status);
|
||||
const body = await res.json();
|
||||
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
||||
groups.clear();
|
||||
for (const g of body.data || []) {
|
||||
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
|
||||
}
|
||||
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
|
||||
rebuildChannelMenu();
|
||||
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
|
||||
applyStatus(snap.status);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
|
||||
if (!res.ok) throw new Error('transcript ' + res.status);
|
||||
const body = await res.json();
|
||||
const entries = (body.data || []).slice();
|
||||
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
entries.forEach(upsertEntry);
|
||||
async function loadState() {
|
||||
try {
|
||||
const res = await moduleApi.fetch('/state');
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
applySnapshot(await res.json());
|
||||
} catch { /* the stream's own snapshot covers it */ }
|
||||
}
|
||||
|
||||
/* ── live stream ────────────────────────────────────────────────── */
|
||||
|
||||
const SSE_EVENT_NAMES = [
|
||||
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
|
||||
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
|
||||
'updated', 'complete', 'completed',
|
||||
];
|
||||
|
||||
function connect() {
|
||||
const onEvent = (ev) => handleEventData(ev.data);
|
||||
const events = {};
|
||||
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
|
||||
|
||||
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() {
|
||||
moduleApi.setStatus('ok', 'Live');
|
||||
// refresh channels/groups and backfill entries missed while
|
||||
// disconnected (upsertEntry dedupes by id, so this is harmless)
|
||||
loadChannels().catch(() => {});
|
||||
loadGroups().catch(() => {});
|
||||
loadHistory().catch(() => {});
|
||||
loadState(); // a late joiner, or a reconnect: catch up on what was missed
|
||||
},
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
|
||||
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
|
||||
},
|
||||
events: {
|
||||
state(ev) {
|
||||
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
|
||||
},
|
||||
entry(ev) {
|
||||
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
|
||||
},
|
||||
clear() {
|
||||
clearView();
|
||||
},
|
||||
status(ev) {
|
||||
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
|
||||
},
|
||||
},
|
||||
message: onEvent,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
async function boot() {
|
||||
if (stopped) return;
|
||||
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
||||
try {
|
||||
await loadChannels();
|
||||
await loadGroups().catch(() => {}); // groups are optional
|
||||
await loadHistory();
|
||||
} catch {
|
||||
moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
|
||||
bootTimer = setTimeout(boot, 5000);
|
||||
return;
|
||||
}
|
||||
scrollToLatest();
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
boot();
|
||||
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
||||
connect();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(bootTimer);
|
||||
sizeWatch.disconnect();
|
||||
stream?.close();
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.3",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript stream",
|
||||
"description": "Live ProdCom transcript",
|
||||
"permissions": [
|
||||
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching the ProdCom API on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 2 },
|
||||
"defaultSize": { "w": 4, "h": 4 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 2
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"configSchema": {
|
||||
"prodcom": {
|
||||
"type": "endpoint",
|
||||
"label": "ProdCom server",
|
||||
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 24480
|
||||
},
|
||||
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "password",
|
||||
@@ -27,18 +40,8 @@
|
||||
"instanceSchema": {
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"label": "Channel icons",
|
||||
"default": true
|
||||
},
|
||||
"newestFirst": {
|
||||
"type": "boolean",
|
||||
"label": "Newest first (transcript reads bottom-up)",
|
||||
"default": false
|
||||
},
|
||||
"showTimes": {
|
||||
"type": "boolean",
|
||||
"label": "Show timestamps",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,73 +3,34 @@
|
||||
/**
|
||||
* ProdCom Transcript — server part.
|
||||
*
|
||||
* One wildcard route that proxies /prodcom/* (including the SSE transcript
|
||||
* stream) to the ProdCom API configured in the admin page, so browsers never
|
||||
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
|
||||
* Ported from prodcom-listener/server.js.
|
||||
* One connection to ProdCom for the whole server, however many dashboards
|
||||
* show the transcript. The module holds the channel list, the groups and
|
||||
* the last entries, follows ProdCom's SSE stream, and hands every browser a
|
||||
* snapshot on connect plus each change as it happens. (It used to proxy each
|
||||
* browser's stream to ProdCom one to one — N screens meant N upstream
|
||||
* connections; ProdCom now sees one.)
|
||||
*
|
||||
* Routes (under /api/modules/prodcom-transcript):
|
||||
* GET /state → { status, channels, groups, entries } (entries by date)
|
||||
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
|
||||
* changed entry, `clear`, and `status` when the link changes
|
||||
*
|
||||
* ProdCom's API, as the reference listener speaks it:
|
||||
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
|
||||
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
|
||||
* { type, entry | data } wrapper, in a few historical shapes (see
|
||||
* extractEntries). A bearer API key is sent when one is configured.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
// Hop-by-hop headers that must not be forwarded
|
||||
const HOP_HEADERS = new Set([
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||||
]);
|
||||
|
||||
/** Health shown in the admin page: outcome of the most recent proxied request. */
|
||||
let health = { status: 'connecting', message: 'No traffic yet' };
|
||||
|
||||
function proxy(req, res, target, apiKey) {
|
||||
const upstreamPath = (req.wildcard || '/') + (req.search || '');
|
||||
const headers = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
||||
}
|
||||
headers.host = target.host;
|
||||
delete headers['accept-encoding']; // keep SSE unbuffered and simple
|
||||
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
|
||||
|
||||
const preq = http.request(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port || 80,
|
||||
path: upstreamPath,
|
||||
method: req.method,
|
||||
headers,
|
||||
},
|
||||
(pres) => {
|
||||
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
|
||||
const outHeaders = {};
|
||||
for (const [k, v] of Object.entries(pres.headers)) {
|
||||
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
|
||||
}
|
||||
res.writeHead(pres.statusCode, outHeaders);
|
||||
// Flush immediately — SSE streams may send no bytes for a while, and
|
||||
// the browser needs the headers to consider the stream open.
|
||||
res.flushHeaders();
|
||||
pres.pipe(res);
|
||||
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
|
||||
// the browser's response, leaving SSE clients hanging on a dead
|
||||
// stream. Destroy it so they notice immediately and reconnect.
|
||||
pres.on('close', () => {
|
||||
if (!res.writableEnded) res.destroy();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
preq.on('error', (e) => {
|
||||
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
|
||||
});
|
||||
|
||||
// Tear down the upstream connection when the browser disconnects
|
||||
res.on('close', () => preq.destroy());
|
||||
req.pipe(preq);
|
||||
}
|
||||
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
||||
const ENTRIES_MAX = 300; // entries kept in memory
|
||||
const RETRY_MIN_MS = 2000;
|
||||
const RETRY_MAX_MS = 15000;
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||
before the endpoint field existed) can only still be present until the
|
||||
@@ -82,23 +43,284 @@ function resolveTarget(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
||||
function extractEntries(obj) {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
||||
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
||||
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
||||
if (obj[key] && typeof obj[key] === 'object') {
|
||||
const found = extractEntries(obj[key]);
|
||||
if (found.length) return found;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
|
||||
function normalizeEntry(e) {
|
||||
const id = e.id !== undefined && e.id !== null && e.id !== '' ? String(e.id) : `${e.channelId || ''}|${e.date || ''}`;
|
||||
return {
|
||||
id,
|
||||
channelId: e.channelId === undefined || e.channelId === null ? '' : String(e.channelId),
|
||||
channelName: e.channelName ? String(e.channelName) : '',
|
||||
text: String(e.text ?? ''),
|
||||
date: e.date ? String(e.date) : new Date().toISOString(),
|
||||
inProgress: Boolean(e.inProgress),
|
||||
};
|
||||
}
|
||||
|
||||
let current = null; // the running instance, for routes()
|
||||
|
||||
module.exports = {
|
||||
init({ config }) {
|
||||
health = resolveTarget(config)
|
||||
? { status: 'connecting', message: 'No traffic yet' }
|
||||
: { status: 'error', message: 'No ProdCom server configured' };
|
||||
init({ config, log }) {
|
||||
const target = resolveTarget(config);
|
||||
const apiKey = String(config.apiKey || '');
|
||||
const channels = [];
|
||||
const groups = [];
|
||||
const entries = new Map(); // id → entry, in arrival order
|
||||
const streams = new Set(); // browsers' SSE responses
|
||||
let status = { state: 'connecting', message: target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
|
||||
let stopped = false;
|
||||
let upstream = null;
|
||||
let generation = 0; // callbacks from a superseded connection are ignored
|
||||
let retryTimer = null;
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
|
||||
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
|
||||
|
||||
async function getJson(pathname) {
|
||||
const res = await fetch(new URL(pathname, target), {
|
||||
headers: { accept: 'application/json', ...authHeaders() },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function sortedEntries() {
|
||||
return [...entries.values()].sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { status, channels, groups, entries: sortedEntries() };
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
if (!streams.size) return;
|
||||
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(frame);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state, message) {
|
||||
if (status.state === state && status.message === message) return;
|
||||
status = { state, message };
|
||||
broadcast('status', status);
|
||||
}
|
||||
|
||||
function trim() {
|
||||
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
|
||||
/** A changed entry: kept, and told to every browser. */
|
||||
function upsert(raw) {
|
||||
const e = normalizeEntry(raw);
|
||||
const prev = entries.get(e.id);
|
||||
if (prev && prev.text === e.text && prev.inProgress === e.inProgress && prev.channelId === e.channelId) return;
|
||||
entries.set(e.id, e);
|
||||
trim();
|
||||
broadcast('entry', e);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
entries.clear();
|
||||
broadcast('clear', {});
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
const [ch, gr] = await Promise.all([
|
||||
getJson('/api/v1/channels'),
|
||||
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
|
||||
]);
|
||||
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
|
||||
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const body = await getJson(`/api/v1/transcript?limit=${HISTORY_LIMIT}`);
|
||||
for (const e of extractEntries(body).map(normalizeEntry)) entries.set(e.id, e);
|
||||
trim();
|
||||
}
|
||||
|
||||
function handleFrame(block) {
|
||||
const data = [];
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
if (!data.length) return;
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(data.join('\n'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const kind = String(obj?.type || obj?.event || '').toLowerCase();
|
||||
if (kind.includes('clear')) return clearAll();
|
||||
extractEntries(obj).forEach(upsert);
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (stopped) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
|
||||
}
|
||||
|
||||
function fail(gen, why) {
|
||||
if (stopped || gen !== generation) return;
|
||||
generation += 1; // whatever else this connection reports is stale now
|
||||
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||
upstream = null;
|
||||
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
|
||||
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
|
||||
async function connect() {
|
||||
if (stopped || !target) return;
|
||||
const gen = ++generation;
|
||||
try {
|
||||
await loadLists();
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
if (gen !== generation || stopped) return;
|
||||
return fail(gen, err.cause?.code || err.code || err.message);
|
||||
}
|
||||
if (gen !== generation || stopped) return;
|
||||
broadcast('state', snapshot());
|
||||
const url = new URL('/api/v1/transcript/stream', target);
|
||||
const req = http.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || 80,
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
headers: { accept: 'text/event-stream', ...authHeaders() },
|
||||
}, (res) => {
|
||||
if (gen !== generation) return void res.destroy();
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
|
||||
}
|
||||
retryMs = RETRY_MIN_MS;
|
||||
setStatus('ok', 'Live');
|
||||
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
|
||||
let buf = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
if (gen !== generation) return;
|
||||
buf += chunk;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n\n')) >= 0) {
|
||||
handleFrame(buf.slice(0, i));
|
||||
buf = buf.slice(i + 2);
|
||||
}
|
||||
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
|
||||
});
|
||||
res.on('end', () => fail(gen, 'stream ended'));
|
||||
res.on('error', (err) => fail(gen, err.code || err.message));
|
||||
});
|
||||
req.on('error', (err) => fail(gen, err.code || err.message));
|
||||
req.end();
|
||||
upstream = req;
|
||||
}
|
||||
|
||||
const listsTimer = setInterval(() => {
|
||||
if (stopped || status.state !== 'ok') return;
|
||||
loadLists().then(() => broadcast('state', snapshot())).catch(() => { /* the next reconnect re-reads them */ });
|
||||
}, REFRESH_LISTS_MS);
|
||||
listsTimer.unref?.();
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
if (target) {
|
||||
log(`one connection to ProdCom at ${target.origin}, shared by every dashboard`);
|
||||
connect();
|
||||
} else {
|
||||
log('no ProdCom server configured — set the endpoint in /admin');
|
||||
}
|
||||
|
||||
current = {
|
||||
snapshot,
|
||||
streams,
|
||||
health() {
|
||||
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
||||
// Nothing to connect to is a setting that is missing, not a link that
|
||||
// is coming up — it reads red, like the other modules say it.
|
||||
if (!target) return { status: 'error', message: status.message };
|
||||
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||
health: () => health,
|
||||
stop() {
|
||||
stopped = true;
|
||||
generation += 1;
|
||||
clearTimeout(retryTimer);
|
||||
clearInterval(listsTimer);
|
||||
clearInterval(heartbeat);
|
||||
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current?.streams === streams) current = null;
|
||||
},
|
||||
health: () => (current ? current.health() : { status: 'connecting', message: 'Restarting…' }),
|
||||
};
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||
log(`proxying /prodcom/* → ${target.origin}`);
|
||||
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 {
|
||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||
'GET /state': (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
|
||||
sendJson(res, 200, current.snapshot());
|
||||
},
|
||||
'GET /stream': (req, res) => {
|
||||
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');
|
||||
if (!current) {
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ status: { state: 'connecting', message: 'ProdCom module is restarting…' }, channels: [], groups: [], entries: [] })}\n\n`);
|
||||
return void res.end();
|
||||
}
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||
|
||||
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
in /admin; the tile only renders. */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* "now" is the live item on its own, filling the tile; "full" is that plus
|
||||
the rest of the playlist. The picker presets `view`; a tile placed before
|
||||
this module had a picker has neither, and gets the full card. */
|
||||
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
|
||||
const nowOnly = view === 'now';
|
||||
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
|
||||
</div>`;
|
||||
|
||||
const screenEl = root.querySelector('.nn-screen');
|
||||
if (nowOnly) screenEl.classList.add('nn-only-now');
|
||||
const topRow = root.querySelector('.nn-top');
|
||||
const playlistNameEl = root.querySelector('.nn-playlist');
|
||||
const liveEl = root.querySelector('.nn-live');
|
||||
const liveTextEl = root.querySelector('.nn-live-text');
|
||||
const nowEl = root.querySelector('.nn-now');
|
||||
const nowLabelEl = root.querySelector('.nn-label');
|
||||
const nowTitleBox = root.querySelector('.nn-title-box');
|
||||
let nowTitleEl = root.querySelector('.nn-title');
|
||||
const nowMetaEl = root.querySelector('.nn-meta');
|
||||
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
|
||||
const nextListEl = root.querySelector('.nn-next-list');
|
||||
const nextMoreEl = root.querySelector('.nn-next-more');
|
||||
|
||||
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
|
||||
function prefs() {
|
||||
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
|
||||
return moduleApi.instanceSettings;
|
||||
}
|
||||
const on = (key) => prefs()[key] !== false;
|
||||
|
||||
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
|
||||
|
||||
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function displayName(raw) {
|
||||
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
|
||||
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
|
||||
}
|
||||
|
||||
function itemTypeTag(type) {
|
||||
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
|
||||
const current = state.currentSlide || 0;
|
||||
if (total > 0 && current > 0) {
|
||||
if (!on('showSlideCount')) {
|
||||
nowMetaEl.textContent = '';
|
||||
} else if (total > 0 && current > 0) {
|
||||
const remaining = Math.max(0, total - current);
|
||||
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
|
||||
} else if (total > 0) {
|
||||
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
|
||||
nowMetaEl.textContent = '';
|
||||
}
|
||||
|
||||
if (prefs().showProgress && total > 0) {
|
||||
if (on('showProgress') && total > 0) {
|
||||
progressEl.hidden = false;
|
||||
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
|
||||
progressFillEl.style.width = `${pct}%`;
|
||||
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
|
||||
nameEl.textContent = displayName(item.name) || '(untitled)';
|
||||
row.appendChild(nameEl);
|
||||
|
||||
if (!on('showTags')) {
|
||||
nextListEl.appendChild(row);
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'nn-count-chip';
|
||||
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
|
||||
nextListEl.appendChild(row);
|
||||
}
|
||||
|
||||
nextLabelEl.hidden = upcoming.length === 0;
|
||||
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
|
||||
nextMoreEl.hidden = true;
|
||||
markNextOverflow();
|
||||
requestAnimationFrame(markNextOverflow);
|
||||
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
function render() {
|
||||
screenEl.classList.toggle('is-offline', feedOffline);
|
||||
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
|
||||
topRow.classList.toggle('hidden-row', !showTop);
|
||||
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
|
||||
// Trouble always shows, whatever is switched off: a tile that has stopped
|
||||
// updating must never look like one with nothing live.
|
||||
const trouble = feedOffline || !state?.reachable || !state?.enabled;
|
||||
const wantPlaylist = on('showPlaylist');
|
||||
const wantLive = on('showLive') || trouble;
|
||||
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
|
||||
liveEl.hidden = !wantLive;
|
||||
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
|
||||
// the row open and pushes the live dot off the right edge.
|
||||
playlistNameEl.hidden = !wantPlaylist;
|
||||
playlistNameEl.textContent = state?.playlistName || '';
|
||||
nowLabelEl.hidden = !on('showNowLabel');
|
||||
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
|
||||
renderLive();
|
||||
renderNow();
|
||||
renderNext();
|
||||
if (!nowOnly && on('showNext')) renderNext();
|
||||
}
|
||||
|
||||
/* ── sizing: --unit scales the whole card to the tile ───────────── */
|
||||
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
|
||||
function applyUnit() {
|
||||
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
|
||||
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
|
||||
// On a Now tile the top row floats over the gradient, so at the shortest
|
||||
// sizes it would sit on the title. Below the height it needs, it goes —
|
||||
// the header dot and the title itself still report trouble.
|
||||
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,32 +1,100 @@
|
||||
{
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.0.1",
|
||||
"proddash": ">=1.1.0",
|
||||
"version": "1.1.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"permissions": [
|
||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 4, "h": 5 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 5
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": { "host": "", "port": 1025 }
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password (only if set in ProPresenter)",
|
||||
"label": "Password",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
|
||||
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
|
||||
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
|
||||
"cleanTitles": {
|
||||
"type": "switch",
|
||||
"label": "Tidy titles",
|
||||
"default": true
|
||||
},
|
||||
"showPlaylist": {
|
||||
"type": "switch",
|
||||
"label": "Playlist",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLive": {
|
||||
"type": "switch",
|
||||
"label": "Live status",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNowLabel": {
|
||||
"type": "switch",
|
||||
"label": "Now label",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSlideCount": {
|
||||
"type": "switch",
|
||||
"label": "Slide count",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showProgress": {
|
||||
"type": "switch",
|
||||
"label": "Progress bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNext": {
|
||||
"type": "switch",
|
||||
"label": "Next list",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNextLabel": {
|
||||
"type": "switch",
|
||||
"label": "Next label",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTags": {
|
||||
"type": "switch",
|
||||
"label": "Item tags",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,43 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Two ways to place this module. "Now / Next" is the full card — what is
|
||||
* live, then the rest of the playlist. "Now" is the same live item alone,
|
||||
* edge to edge with the gradient filling the tile: a wall display that says
|
||||
* one thing. The Now entry offers only the settings it can act on, so its
|
||||
* gear carries no Next switches.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'full',
|
||||
name: 'Now / Next',
|
||||
description: 'What is live, and the rest of the playlist',
|
||||
defaultSize: { w: 4, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
settings: { view: 'full' },
|
||||
},
|
||||
{
|
||||
id: 'now',
|
||||
name: 'Now',
|
||||
description: 'Only what is live, filling the tile',
|
||||
defaultSize: { w: 4, h: 2 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
settings: { view: 'now' },
|
||||
instanceSchema: {
|
||||
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
|
||||
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
|
||||
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
|
||||
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
|
||||
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
|
||||
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
|
||||
@@ -21,6 +21,51 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
|
||||
|
||||
/* ── the "Now" tile ──
|
||||
Only what is live, edge to edge: the gradient moves off the card and onto
|
||||
the whole tile, the card loses its border, radius and box, and the title
|
||||
sits in the middle of everything. Nothing is inset, so a wall of these
|
||||
reads as panels of colour rather than cards on a background. */
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
|
||||
gap: 0;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--bg) 70%, transparent)
|
||||
);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
/* The live dot and playlist ride over the gradient rather than above it. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
|
||||
position: absolute;
|
||||
inset: var(--nn-pad) var(--nn-pad) auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
|
||||
/* The overlay floats, so it takes no room — this keeps the title clear of it
|
||||
and still centred, by holding back as much at the bottom as at the top. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
|
||||
padding: calc(var(--unit) * 1.4) 0;
|
||||
}
|
||||
/* Too short for the overlay: the title gets the whole panel to itself. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
|
||||
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── top row ── */
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-top {
|
||||
@@ -59,6 +104,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-live {
|
||||
display: flex;
|
||||
@@ -71,6 +117,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-live-dot {
|
||||
width: calc(var(--unit) * 0.6);
|
||||
@@ -122,6 +169,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-title-box {
|
||||
flex: 1;
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,26 @@
|
||||
/**
|
||||
* 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
|
||||
* Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
|
||||
* shipped prebuilt; source in native/) 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
|
||||
* { error: '<short, for the tiles>', detail: '<for admin/log>' } 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.
|
||||
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||
* for the admin health line and the log.
|
||||
*
|
||||
* The capture tool ships prebuilt inside the module, one binary per
|
||||
* platform under bin/ (module guide, "Native helpers"): nothing is ever
|
||||
* built on the machine running ProdDash. captureTool() picks the binary for
|
||||
* this platform; when there is none, timecode reports itself off and the
|
||||
* rest of the module carries on. A dead capture child is restarted with
|
||||
* backoff — fast for a crash, slow for config-shaped failures (unknown
|
||||
* device, ambiguity) 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
|
||||
@@ -27,7 +33,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { spawn } = require('child_process');
|
||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
@@ -41,13 +47,43 @@ const RUNNING_WINDOW_MS = 400;
|
||||
/** stderr line the capture prints once the device is open. */
|
||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||
|
||||
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||
|
||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||
|
||||
/** What the module ships in bin/, by platform and architecture. */
|
||||
const HELPERS = {
|
||||
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The capture tool for this machine. It is never built here and never
|
||||
* looked for anywhere else: the module ships it, or timecode is off on this
|
||||
* platform and says so. `ready` means a usable binary at `bin`; otherwise
|
||||
* `error` is the short tile text and `detail` names the platform.
|
||||
*
|
||||
* @returns {{ready:boolean, bin:string, error:string, detail:string}}
|
||||
*/
|
||||
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||
const name = HELPERS[platform]?.[arch] || '';
|
||||
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||
if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
|
||||
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||
return {
|
||||
ready: false,
|
||||
bin: '',
|
||||
error: TOOL_MISSING,
|
||||
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @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;
|
||||
@@ -75,25 +111,13 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
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 });
|
||||
const tool = captureTool(moduleDir);
|
||||
if (!tool.ready) {
|
||||
// No binary for this platform: say so and look again later — a module
|
||||
// update remounts us anyway, this is only belt and braces.
|
||||
report({ error: tool.error, detail: tool.detail });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
@@ -103,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
let decoder = null;
|
||||
let stderrTail = '';
|
||||
|
||||
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (text) => {
|
||||
@@ -113,7 +137,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
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.` });
|
||||
report({
|
||||
error: 'Channel not on this device — see Admin',
|
||||
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
|
||||
});
|
||||
try { child.kill(); } catch { /* exiting anyway */ }
|
||||
return;
|
||||
}
|
||||
@@ -135,17 +162,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||
report({ error: TOOL_MISSING, detail: `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.
|
||||
// permission hint) is the most useful DETAIL we have; the tile just
|
||||
// needs to know the input is down and where to fix it.
|
||||
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') });
|
||||
report({
|
||||
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
|
||||
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
|
||||
});
|
||||
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||
});
|
||||
}
|
||||
@@ -188,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLtcListener };
|
||||
module.exports = { createLtcListener, captureTool };
|
||||
|
||||
@@ -1,34 +1,73 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.2.1",
|
||||
"proddash": ">=1.2.0",
|
||||
"description": "Live ProPresenter timers and LTC timecode",
|
||||
"version": "1.7.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
"permissions": [
|
||||
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||
{
|
||||
"kind": "microphone",
|
||||
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
|
||||
},
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 4, "h": 2 },
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 3
|
||||
},
|
||||
"configGroups": {
|
||||
"ProPresenter": {
|
||||
"toggle": "ppEnabled",
|
||||
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
|
||||
},
|
||||
"LTC Timecode": {
|
||||
"toggle": "ltcEnabled",
|
||||
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
|
||||
},
|
||||
"PCO Plan": {
|
||||
"toggle": "pcoEnabled",
|
||||
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"ppEnabled": {
|
||||
"type": "switch",
|
||||
"label": "ProPresenter timers",
|
||||
"group": "ProPresenter",
|
||||
"default": true
|
||||
},
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"group": "ProPresenter",
|
||||
"default": { "host": "", "port": 1025 }
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password (only if set in ProPresenter)",
|
||||
"label": "API password",
|
||||
"group": "ProPresenter",
|
||||
"default": ""
|
||||
"default": "",
|
||||
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||
},
|
||||
"ltcEnabled": {
|
||||
"type": "switch",
|
||||
"label": "LTC listener — decode timecode from an audio input on this machine",
|
||||
"label": "LTC timecode",
|
||||
"group": "LTC Timecode",
|
||||
"default": false
|
||||
},
|
||||
@@ -42,10 +81,17 @@
|
||||
},
|
||||
"ltcChannel": {
|
||||
"type": "number",
|
||||
"label": "Channel on that device (1-based)",
|
||||
"label": "Channel",
|
||||
"group": "LTC Timecode",
|
||||
"showWhen": "ltcEnabled",
|
||||
"default": 1
|
||||
"default": 1,
|
||||
"help": "The channel on that device carrying the timecode, counted from 1."
|
||||
},
|
||||
"pcoEnabled": {
|
||||
"type": "switch",
|
||||
"label": "PCO Plan timers",
|
||||
"group": "PCO Plan",
|
||||
"default": true
|
||||
},
|
||||
"timerFont": {
|
||||
"type": "select",
|
||||
@@ -53,9 +99,136 @@
|
||||
"group": "Display",
|
||||
"default": "default",
|
||||
"options": [
|
||||
{ "value": "default", "label": "Default (system)" },
|
||||
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
||||
{
|
||||
"value": "default",
|
||||
"label": "Default (system)"
|
||||
},
|
||||
{
|
||||
"value": "monospace",
|
||||
"label": "Monospace — steadier changing digits"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
},
|
||||
"Clock": {
|
||||
"collapsed": false,
|
||||
"columns": 2
|
||||
},
|
||||
"Colours": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showNames": {
|
||||
"type": "switch",
|
||||
"label": "Names",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStatus": {
|
||||
"type": "switch",
|
||||
"label": "Status lines",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDetail": {
|
||||
"type": "switch",
|
||||
"label": "Detail lines",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSeconds": {
|
||||
"type": "switch",
|
||||
"label": "Seconds",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"leadingZeros": {
|
||||
"type": "switch",
|
||||
"label": "Leading zeros",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"groupBySource": {
|
||||
"type": "switch",
|
||||
"label": "Group by source",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLoops": {
|
||||
"type": "switch",
|
||||
"label": "Looping videos",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeadings": {
|
||||
"type": "switch",
|
||||
"label": "Source headings",
|
||||
"default": true,
|
||||
"group": "Show",
|
||||
"showWhen": "groupBySource"
|
||||
},
|
||||
"clock24h": {
|
||||
"type": "switch",
|
||||
"label": "24-hour",
|
||||
"default": true,
|
||||
"group": "Clock"
|
||||
},
|
||||
"clockSeconds": {
|
||||
"type": "switch",
|
||||
"label": "Seconds",
|
||||
"default": true,
|
||||
"group": "Clock"
|
||||
},
|
||||
"clockDate": {
|
||||
"type": "switch",
|
||||
"label": "Date",
|
||||
"default": false,
|
||||
"group": "Clock"
|
||||
},
|
||||
"dateShortDay": {
|
||||
"type": "switch",
|
||||
"label": "Short day",
|
||||
"default": false,
|
||||
"group": "Clock",
|
||||
"showWhen": "clockDate"
|
||||
},
|
||||
"dateShortMonth": {
|
||||
"type": "switch",
|
||||
"label": "Short month",
|
||||
"default": false,
|
||||
"group": "Clock",
|
||||
"showWhen": "clockDate"
|
||||
},
|
||||
"runningColor": {
|
||||
"type": "color",
|
||||
"label": "Running",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colours"
|
||||
},
|
||||
"warningColor": {
|
||||
"type": "color",
|
||||
"label": "Warning",
|
||||
"default": "#f0b429",
|
||||
"group": "Colours"
|
||||
},
|
||||
"overrunColor": {
|
||||
"type": "color",
|
||||
"label": "Overrun",
|
||||
"default": "#f4433c",
|
||||
"group": "Colours"
|
||||
},
|
||||
"idleColor": {
|
||||
"type": "color",
|
||||
"label": "Idle",
|
||||
"default": "#8b98a5",
|
||||
"group": "Colours"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/zsh
|
||||
# Build this module's native helper for every macOS architecture it ships —
|
||||
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
|
||||
# from ltc-capture.swift beside this script. Run it on any Mac with the
|
||||
# Xcode command line tools; both architectures cross-compile from either.
|
||||
# The binaries are committed: a machine running ProdDash never builds
|
||||
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||
#
|
||||
# Signed with a Developer ID Application certificate when one is installed
|
||||
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||
# entitlement, which a helper that reads a microphone needs under the
|
||||
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||
#
|
||||
# Windows is not built here: there is no Windows implementation of this
|
||||
# tool yet, so on Windows the module reports timecode off.
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
NAME="ltc-capture"
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
command -v swiftc >/dev/null || {
|
||||
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
fi
|
||||
if [[ -n "$IDENTITY" ]]; then
|
||||
echo "signing as $IDENTITY"
|
||||
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||
else
|
||||
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
|
||||
SIGN=(--force --sign - --options runtime)
|
||||
fi
|
||||
|
||||
mkdir -p ../bin
|
||||
for ARCH in arm64 x86_64; do
|
||||
OUT="../bin/$NAME-macos-$ARCH"
|
||||
echo " compiling $OUT"
|
||||
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||
codesign --verify --strict "$OUT"
|
||||
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||
done
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The helper reads an audio input; under the hardened runtime that
|
||||
needs saying. The permission itself comes from the launcher. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+5
-5
@@ -4,12 +4,12 @@
|
||||
// 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:
|
||||
// overkill — this is the whole job, natively. It ships prebuilt in the
|
||||
// module's bin/ (one binary per platform, committed — a booth never
|
||||
// builds anything). To rebuild after a change here, on any Mac with the
|
||||
// Xcode command line tools:
|
||||
//
|
||||
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
||||
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
||||
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
|
||||
//
|
||||
// ltc-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Phase lock for whole-second timer reports (the ProPresenter poller).
|
||||
*
|
||||
* ProPresenter reports a running timer as whole seconds — trunc(value):
|
||||
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
|
||||
* old poller re-anchored a tile's local count at the bottom of that interval
|
||||
* on every poll, so the displayed digit ran up to a second ahead and then
|
||||
* got yanked back a step whenever a fresh poll happened to sample the next
|
||||
* flip later than the last one did — the poll clock and ProPresenter's
|
||||
* second boundaries drift through each other, so tiles flickered
|
||||
* "…12:33 → 12:34 → 12:33…" about once a second.
|
||||
*
|
||||
* This tracker turns the quantized reports into ONE stable anchor
|
||||
* { valueMs, at } — the continuous value at a fixed instant — which the
|
||||
* client extrapolates from. Rules:
|
||||
*
|
||||
* - The only real information in a poll is a FLIP: the report stepping one
|
||||
* second in the direction of travel. At the flip instant the true value
|
||||
* equals the interval boundary just crossed, and the flip happened between
|
||||
* the last two polls, so their midpoint dates it to within half a poll.
|
||||
* - While a poll's report matches what the anchor predicts (within TOL_MS
|
||||
* around the reported second's interval), the anchor HOLDS — the same
|
||||
* numbers are republished, so consumers count along one steady clock
|
||||
* instead of a freshly re-phased one per sample.
|
||||
* - A report the anchor can't explain re-anchors: at the flip boundary when
|
||||
* this poll saw a clean one-second flip, else mid-interval (first sight,
|
||||
* an operator reset/seek, a resume, or clock drift past TOL_MS).
|
||||
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
|
||||
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
|
||||
* whole-second bias one way.
|
||||
*
|
||||
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
|
||||
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
|
||||
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
|
||||
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
|
||||
* crosses +1 s.
|
||||
*/
|
||||
|
||||
/** How far a report may sit outside the anchor's prediction before it means
|
||||
something real. A flip is dated to the midpoint of two polls, so its own
|
||||
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
|
||||
runs long); the tolerance must sit ABOVE that or consecutive flips
|
||||
re-anchor each other forever, and safely below a full second so a real
|
||||
one-second seek still registers. */
|
||||
const TOL_MS = 450;
|
||||
|
||||
/** The continuous-value interval implied by a whole-second report. */
|
||||
function intervalFor(kind, wholeMs) {
|
||||
if (kind === 'countdown') {
|
||||
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
|
||||
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
|
||||
}
|
||||
return { lo: wholeMs, hi: wholeMs + 1000 };
|
||||
}
|
||||
|
||||
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
|
||||
function boundaryFor(kind, wholeMs) {
|
||||
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
|
||||
return wholeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tracker per timer. Feed one observation per poll; get back what to
|
||||
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
|
||||
* stable across polls while the reports stay consistent with it. Not
|
||||
* running (or no value): the report is returned verbatim, dated at the poll.
|
||||
*/
|
||||
function createTimerPhase() {
|
||||
let anchor = null; // { valueMs, at }
|
||||
let kind = '';
|
||||
let lastWhole = null;
|
||||
let lastAt = 0;
|
||||
let wasRunning = false;
|
||||
|
||||
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
|
||||
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
|
||||
const dir = k === 'countdown' ? -1 : 1;
|
||||
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
|
||||
&& lastWhole !== null && wholeMs !== null
|
||||
&& wholeMs - lastWhole === dir * 1000;
|
||||
const prevAt = lastAt;
|
||||
lastWhole = wholeMs;
|
||||
lastAt = pollAt;
|
||||
wasRunning = Boolean(running);
|
||||
if (k !== kind) anchor = null;
|
||||
kind = k;
|
||||
if (!running || wholeMs === null) {
|
||||
anchor = null;
|
||||
return { valueMs: wholeMs, asOf: pollAt };
|
||||
}
|
||||
const { lo, hi } = intervalFor(k, wholeMs);
|
||||
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
|
||||
// tolerance check may fail to catch for a long time (its error only
|
||||
// shows at a poll landing more than TOL_MS past a flip). The first
|
||||
// clean flip is strictly better evidence (±½ poll), so it always wins.
|
||||
if (anchor && !(anchor.provisional && flip)) {
|
||||
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
|
||||
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
|
||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
||||
}
|
||||
}
|
||||
anchor = flip
|
||||
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
|
||||
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
|
||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
||||
}
|
||||
|
||||
return { observe };
|
||||
}
|
||||
|
||||
module.exports = { createTimerPhase, TOL_MS };
|
||||
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for phase.js — the whole-second phase lock — plus the client's
|
||||
* never-tick-backwards guard, driven by a simulated ProPresenter:
|
||||
*
|
||||
* node modules/propresenter-timers/phase.selftest.js
|
||||
*
|
||||
* The simulation runs a true continuous timer, reports it the way
|
||||
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
|
||||
* jitter), feeds the reports through createTimerPhase(), then renders the
|
||||
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
|
||||
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
|
||||
*
|
||||
* - the displayed digits NEVER step against the direction of travel
|
||||
* (the reported bug: countdown tiles flicking back to the previous
|
||||
* second about once a second),
|
||||
* - each displayed second dwells ~1 s,
|
||||
* - the published anchor stays within tolerance of the true value,
|
||||
* - operator resets/seeks snap within a poll or two,
|
||||
* - pause → resume re-locks,
|
||||
* - a ProPresenter whose clock drifts against the server's stays locked.
|
||||
*/
|
||||
|
||||
const { createTimerPhase, TOL_MS } = require('./phase');
|
||||
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
function ok(name, cond, detail) {
|
||||
checks += 1;
|
||||
if (!cond) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic pseudo-random (so a failure reproduces). */
|
||||
function makeRand(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
/** ProPresenter's report: whole seconds, truncated toward zero. */
|
||||
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
|
||||
|
||||
/**
|
||||
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
|
||||
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
|
||||
* timers report a frozen value via `frozen`).
|
||||
*/
|
||||
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
|
||||
const rand = makeRand(seed);
|
||||
const phase = createTimerPhase();
|
||||
const dir = kind === 'countdown' ? -1 : 1;
|
||||
|
||||
const polls = [];
|
||||
for (let t = 0; t <= durationMs;) {
|
||||
const running = runningAt(t);
|
||||
const value = trueValueAt(t);
|
||||
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
|
||||
t += pollMs + Math.floor(rand() * jitterMs);
|
||||
}
|
||||
|
||||
// Render like client.js: between polls, extrapolate from the latest
|
||||
// anchor; clamp sub-1.5 s regressions while running.
|
||||
const shown = []; // { t, sec, err }
|
||||
let mono = null;
|
||||
let p = 0;
|
||||
for (let t = polls[0].t; t <= durationMs; t += 25) {
|
||||
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
|
||||
const { out, running } = polls[p];
|
||||
if (!running || out.valueMs === null) {
|
||||
mono = null;
|
||||
continue;
|
||||
}
|
||||
let value = out.valueMs + dir * (t - out.asOf);
|
||||
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
|
||||
mono = value;
|
||||
const truth = trueValueAt(t);
|
||||
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
|
||||
}
|
||||
return { name, dir, shown };
|
||||
}
|
||||
|
||||
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
|
||||
from legitimate corrections (absorbing clock drift re-places a boundary
|
||||
by up to TOL_MS once in a while). */
|
||||
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
|
||||
let regressions = 0;
|
||||
let maxAbsErr = 0;
|
||||
const changes = [];
|
||||
for (let i = 1; i < shown.length; i += 1) {
|
||||
const step = shown[i].sec - shown[i - 1].sec;
|
||||
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
|
||||
if (step !== 0) changes.push(shown[i].t);
|
||||
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
|
||||
}
|
||||
// Whole dwells only (drop the partial first and trailing segments), after
|
||||
// the lock settles — the first observed flip corrects the provisional
|
||||
// mid-interval anchor once, stretching or squeezing a boundary in the
|
||||
// timer's first moments, which no one can see on a wall. A countdown
|
||||
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
|
||||
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
|
||||
// allow one ~2 s dwell; everything else must be ~1 s.
|
||||
const settled = shown.length ? shown[0].t + 2500 : 0;
|
||||
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
|
||||
const near = (d, target) => Math.abs(d - target) <= 200;
|
||||
const long = dwells.filter((d) => near(d, 2000)).length;
|
||||
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
|
||||
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
|
||||
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
|
||||
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
|
||||
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
|
||||
}
|
||||
|
||||
/* 1. Plain countdown, many sub-second phases and poll seeds. */
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const phase0 = (i * 83) % 1000;
|
||||
const run = simulate({
|
||||
name: `countdown q=${phase0}ms seed=${i}`,
|
||||
kind: 'countdown',
|
||||
seed: 1000 + i,
|
||||
durationMs: 30000,
|
||||
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
|
||||
});
|
||||
assertSteady(run);
|
||||
}
|
||||
|
||||
/* 2. Elapsed timer (counts up). */
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const run = simulate({
|
||||
name: `elapsed seed=${i}`,
|
||||
kind: 'elapsed',
|
||||
seed: 2000 + i,
|
||||
durationMs: 25000,
|
||||
trueValueAt: (t) => 400 + i * 137 + t,
|
||||
});
|
||||
assertSteady(run);
|
||||
}
|
||||
|
||||
/* 3. Operator reset mid-run: value jumps back up to full length. */
|
||||
{
|
||||
const run = simulate({
|
||||
name: 'reset',
|
||||
kind: 'countdown',
|
||||
seed: 7,
|
||||
durationMs: 24000,
|
||||
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
|
||||
});
|
||||
// After the reset the shown value must reach the new region within ~2 polls.
|
||||
const after = run.shown.filter((s) => s.t >= 13400);
|
||||
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
|
||||
// Before and after the jump, digits stay monotonic within each phase.
|
||||
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
|
||||
assertSteady(pre, { maxErr: 700 });
|
||||
}
|
||||
|
||||
/* 4. Pause, then resume: no stale-anchor explosion. */
|
||||
{
|
||||
const frozen = 8000;
|
||||
const run = simulate({
|
||||
name: 'pause/resume',
|
||||
kind: 'countdown',
|
||||
seed: 11,
|
||||
durationMs: 26000,
|
||||
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
|
||||
runningAt: (t) => t < 6000 || t >= 14000,
|
||||
});
|
||||
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
|
||||
assertSteady(resumed);
|
||||
}
|
||||
|
||||
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
|
||||
re-locks over time instead of accumulating a visible error. */
|
||||
{
|
||||
const run = simulate({
|
||||
name: 'clock drift',
|
||||
kind: 'countdown',
|
||||
seed: 13,
|
||||
durationMs: 120000,
|
||||
trueValueAt: (t) => 130000 - t * 1.002,
|
||||
});
|
||||
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
|
||||
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
|
||||
// a backwards step (the separate regression check still demands zero).
|
||||
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
|
||||
}
|
||||
|
||||
/* 6. Idle timers pass through untouched. */
|
||||
{
|
||||
const phase = createTimerPhase();
|
||||
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
|
||||
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
|
||||
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
|
||||
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} of ${checks} checks FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`phase.selftest: all ${checks} checks passed`);
|
||||
+1278
-320
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
/* ProPresenter Timers module — scoped to its tiles, shell variables only.
|
||||
The API exposes no per-timer color, so a card's color IS its state:
|
||||
accent = running, muted = stopped, warn = complete, danger = overrun.
|
||||
Overrun must be unmistakable at a distance — danger fill + pulse. */
|
||||
/* Timers module — scoped to its tiles, shell variables only.
|
||||
A card's colour IS its state: running / warning / overrun / idle. The four
|
||||
tones are custom properties on the tile root: they default to the shell's
|
||||
palette (so a dashboard theme reaches them) and the tile's own colour
|
||||
settings, when changed, are set inline on .tm-wrap and win — the
|
||||
documented mechanism. Overrun must be unmistakable across the room:
|
||||
danger fill + pulse. */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-wrap {
|
||||
--tm-time-size: 32px;
|
||||
--tm-ltc-size: 26px;
|
||||
--tm-ok: var(--accent);
|
||||
--tm-warn: var(--warn);
|
||||
--tm-danger: var(--danger);
|
||||
--tm-muted: var(--muted);
|
||||
--tm-meta-size: 11px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -15,7 +20,7 @@
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */
|
||||
/* ── degraded-state banner (the ProdDash feed itself is down) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-banner {
|
||||
flex: none;
|
||||
@@ -30,12 +35,12 @@
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||
|
||||
/* Values still on screen while the feed is down are stale — say so visually.
|
||||
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; }
|
||||
/* Values still on screen while their source is down are stale — say so
|
||||
visually, per group: the LTC card keeps ticking when only ProPresenter
|
||||
is unreachable. When the ProdDash feed is down everything is old. */
|
||||
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
|
||||
[data-module="propresenter-timers"] .tm-card.is-stale,
|
||||
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-hint {
|
||||
flex: 1;
|
||||
@@ -49,7 +54,69 @@
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
||||
|
||||
/* ── card grid (column count set by client.js fit()) ── */
|
||||
/* ── source groups: heading + card grid (column count set by fit()) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-groups {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tm-gap, 8px);
|
||||
padding: var(--tm-pad, 10px);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-group {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin: 0 2px 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-title {
|
||||
flex: none;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* The source's own word while it isn't OK ("Unreachable at …"). */
|
||||
[data-module="propresenter-timers"] .tm-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
|
||||
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
|
||||
|
||||
/* Headings off: a source in trouble, or with nothing to show, still says so. */
|
||||
[data-module="propresenter-timers"] .tm-empty {
|
||||
flex: none;
|
||||
padding: 2px 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
|
||||
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-grid {
|
||||
flex: 1;
|
||||
@@ -57,16 +124,34 @@
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: var(--tm-gap, 8px);
|
||||
padding: var(--tm-pad, 10px);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
||||
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
|
||||
|
||||
/* Compact cards (short tiles): the state line goes, its color stays.
|
||||
The one exception is the LTC card with no timecode API behind it —
|
||||
that message must never be hidden. */
|
||||
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; }
|
||||
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; }
|
||||
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; }
|
||||
/* Grouping off: one grid for every timer, filled row by row. A wrapping
|
||||
flex row instead of a CSS grid, so the last row's cards widen to use the
|
||||
whole width — nothing is left empty. Rows share the height equally. */
|
||||
[data-module="propresenter-timers"] .tm-grid.is-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: stretch;
|
||||
align-items: stretch;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-grid.is-flow .tm-card {
|
||||
flex: 1 1 calc((100% - var(--tm-gap, 8px) * (var(--tm-cols, 1) - 1)) / var(--tm-cols, 1));
|
||||
min-width: 0;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-empty[data-tone="warn"] { color: var(--tm-warn); }
|
||||
|
||||
/* Compact cards (short tiles) and "Status lines" off: the lines under the
|
||||
digits go, the colour stays. A note that explains a missing value (no
|
||||
LTC input, a solo timer no longer offered) must never be hidden. */
|
||||
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
|
||||
[data-module="propresenter-timers"] .tm-compact .tm-detail,
|
||||
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
|
||||
[data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
|
||||
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
|
||||
[data-module="propresenter-timers"] .tm-nonames .tm-name,
|
||||
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
|
||||
|
||||
/* ── one timer card ── */
|
||||
|
||||
@@ -98,13 +183,20 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The label is its own box so a long name ends in an ellipsis (a flex
|
||||
container's bare text won't). */
|
||||
[data-module="propresenter-timers"] .tm-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-time {
|
||||
flex: none;
|
||||
font-size: var(--tm-time-size);
|
||||
font-size: 32px; /* fit() sets the real size per card */
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -113,17 +205,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
|
||||
Applies to timer and LTC values alike (both are .tm-time). */
|
||||
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */
|
||||
[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-status,
|
||||
[data-module="propresenter-timers"] .tm-detail {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
font-size: var(--tm-meta-size);
|
||||
font-size: calc(var(--tm-meta-size) * 0.8); /* smaller than the title; fit() budgets the same share */
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -132,102 +224,109 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-status[hidden],
|
||||
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
|
||||
|
||||
/* ── state looks ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-running {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
|
||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-complete {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
|
||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
|
||||
|
||||
/* A solo tile whose timer was deleted in ProPresenter. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
|
||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
|
||||
|
||||
/* Overrun: readable across the room even on a wall display. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||
border-color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
||||
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
|
||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
[data-module="propresenter-timers"] .tm-detail {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes tm-overrun-pulse {
|
||||
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); }
|
||||
/* A provider's status line carries its own tone ("ahead 1:20" in green). */
|
||||
[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
|
||||
[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
|
||||
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
|
||||
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
|
||||
|
||||
/* The clock's date: one line when it fits, else the day on its own line
|
||||
(renderDate() sizes it to the longer half). */
|
||||
[data-module="propresenter-timers"] .tm-status.is-date {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
text-align: center;
|
||||
line-height: 1.15;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
|
||||
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
|
||||
|
||||
/* ── the LTC card: visually its own thing ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card-ltc {
|
||||
border-style: dashed;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
|
||||
font-size: var(--tm-ltc-size);
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-ltc-badge {
|
||||
flex: none;
|
||||
padding: 0.15em 0.55em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.95em;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
|
||||
color: var(--warn);
|
||||
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
||||
}
|
||||
|
||||
@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. */
|
||||
[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 {
|
||||
/* Explanatory notes read as sentences, not badges. */
|
||||
[data-module="propresenter-timers"] .tm-status.is-note {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||
/* ── state looks (feedbacks) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-ok {
|
||||
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-warn {
|
||||
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
|
||||
|
||||
/* The wall clock is information, not a state — plain text colour. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
|
||||
|
||||
/* Overrun: readable across the room even on a wall display. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-danger {
|
||||
border-color: var(--tm-danger);
|
||||
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
|
||||
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
|
||||
|
||||
@keyframes tm-overrun-pulse {
|
||||
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
|
||||
}
|
||||
|
||||
/* A solo tile whose timer is no longer offered. */
|
||||
|
||||
/* ── the LTC card: visually its own thing ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-ltc-badge {
|
||||
flex: none;
|
||||
padding: 0.15em 0.55em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.95em;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
/* No signal: muted digits, a blinking warning line. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-nosignal {
|
||||
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
|
||||
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||
|
||||
/* ── the timers menu (container styled/positioned by the shell) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 6px 9px;
|
||||
background: none;
|
||||
@@ -241,16 +340,41 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-item .check {
|
||||
width: 14px;
|
||||
flex: none;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
visibility: hidden;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
|
||||
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
||||
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
|
||||
|
||||
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
|
||||
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
|
||||
properties don't reach it — the shell's palette does.) */
|
||||
[data-module="propresenter-timers"] .tm-switch {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--muted) 25%, transparent);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
|
||||
transform: translateX(12px);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-note {
|
||||
padding: 6px 9px;
|
||||
@@ -258,3 +382,11 @@
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the count beside the title-bar timers icon, while some are hidden */
|
||||
[data-module="propresenter-timers"] .tm-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Resi Cues — the tile.
|
||||
*
|
||||
* One number matters: how long until the next cue. It is drawn as large as the
|
||||
* tile allows, with the cue's name above it, because on a wall the operator reads
|
||||
* it from across the booth.
|
||||
*
|
||||
* The countdown ticks locally. The server sends the playhead with the instant it
|
||||
* was true (`estMs` at `estAt`) and whether playback is running; this adds the
|
||||
* elapsed time since. That is what keeps the digits moving every second while
|
||||
* Resi itself only reports every few seconds, and it means a slow poll never
|
||||
* shows as a stalled clock.
|
||||
*/
|
||||
|
||||
const TICK_MS = 250; // redraw faster than a second so the digit turns on time
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let ticker = null;
|
||||
let state = null;
|
||||
|
||||
const settings = () => moduleApi.instanceSettings;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function hms(ms) {
|
||||
const total = Math.max(0, Math.round(ms / 1000));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
/** Where the playhead is right now, extrapolated from the last server word. */
|
||||
function playheadMs() {
|
||||
if (!state || state.estMs === null || state.estMs === undefined) return null;
|
||||
return state.estMs + (state.playing ? Date.now() - state.estAt : 0);
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.textContent = '';
|
||||
const set = settings();
|
||||
|
||||
if (!state || state.status === 'error') {
|
||||
const box = el('div', 'resi-empty');
|
||||
box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected'));
|
||||
root.append(box);
|
||||
return;
|
||||
}
|
||||
|
||||
const here = playheadMs();
|
||||
const wrap = el('div', 'resi-wrap');
|
||||
|
||||
if (state.next) {
|
||||
if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT'));
|
||||
if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue'));
|
||||
const remaining = here === null ? null : state.next.ms - here;
|
||||
const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining));
|
||||
if (remaining !== null && remaining <= 30000) count.classList.add('is-soon');
|
||||
if (!state.playing) count.classList.add('is-held');
|
||||
wrap.append(count);
|
||||
} else {
|
||||
wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING'));
|
||||
wrap.append(el('div', 'resi-cue', state.event || state.decoder || ''));
|
||||
}
|
||||
|
||||
const foot = el('div', 'resi-foot');
|
||||
if (set.showPrevious !== false && state.prev) {
|
||||
foot.append(el('span', 'resi-foot-item',
|
||||
`after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`));
|
||||
}
|
||||
if (set.showPosition !== false && here !== null) {
|
||||
foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`));
|
||||
}
|
||||
if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused'));
|
||||
if (foot.childElementCount) wrap.append(foot);
|
||||
|
||||
if (set.showCueList && state.cues && state.cues.length) {
|
||||
const list = el('ul', 'resi-list');
|
||||
for (const cue of state.cues) {
|
||||
const row = el('li', 'resi-list-row');
|
||||
if (here !== null && cue.ms <= here) row.classList.add('is-past');
|
||||
if (state.next && cue.ms === state.next.ms) row.classList.add('is-next');
|
||||
row.append(el('span', 'resi-list-name', cue.name));
|
||||
row.append(el('span', 'resi-list-at', hms(cue.ms)));
|
||||
list.append(row);
|
||||
}
|
||||
wrap.append(list);
|
||||
}
|
||||
|
||||
root.append(wrap);
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server');
|
||||
if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected');
|
||||
if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`);
|
||||
const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : '';
|
||||
return moduleApi.setStatus('ok', `${state.decoder}${cadence}`);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting');
|
||||
stream = moduleApi.sse('/stream', {
|
||||
state: (data) => {
|
||||
try {
|
||||
state = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
applyStatus();
|
||||
render();
|
||||
},
|
||||
error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'),
|
||||
});
|
||||
ticker = setInterval(render, TICK_MS);
|
||||
render();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
if (ticker) clearInterval(ticker);
|
||||
stream = null;
|
||||
ticker = null;
|
||||
root.textContent = '';
|
||||
},
|
||||
|
||||
onConfigChange() {
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 4, "h": 3 },
|
||||
"configGroups": {
|
||||
"Resi account": {
|
||||
"help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one."
|
||||
},
|
||||
"Decoder": {
|
||||
"help": "Which decoder to follow. The list is read from the account once the login above is saved."
|
||||
},
|
||||
"Timing": {
|
||||
"collapsed": true,
|
||||
"help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"label": "Resi login",
|
||||
"group": "Resi account",
|
||||
"default": "",
|
||||
"help": "The email address used to sign in to studio.resi.io."
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"group": "Resi account",
|
||||
"default": ""
|
||||
},
|
||||
"player": {
|
||||
"type": "select",
|
||||
"label": "Decoder",
|
||||
"group": "Decoder",
|
||||
"default": "",
|
||||
"optionsRoute": "/decoders",
|
||||
"help": "Leave unset to follow the only decoder on the account."
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
"label": "Position poll (s)",
|
||||
"group": "Timing",
|
||||
"default": 1,
|
||||
"help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings."
|
||||
},
|
||||
"cueRefreshSeconds": {
|
||||
"type": "number",
|
||||
"label": "Cue list refresh (s)",
|
||||
"group": "Timing",
|
||||
"default": 5,
|
||||
"help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this."
|
||||
},
|
||||
"positionTrimMs": {
|
||||
"type": "number",
|
||||
"label": "Position trim (ms)",
|
||||
"group": "Timing",
|
||||
"default": 0,
|
||||
"help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showCueName": {
|
||||
"type": "switch",
|
||||
"label": "Cue name",
|
||||
"default": true
|
||||
},
|
||||
"showPrevious": {
|
||||
"type": "switch",
|
||||
"label": "Previous cue",
|
||||
"default": true
|
||||
},
|
||||
"showPosition": {
|
||||
"type": "switch",
|
||||
"label": "Playhead",
|
||||
"default": true
|
||||
},
|
||||
"showCueList": {
|
||||
"type": "switch",
|
||||
"label": "Cue list",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
|
||||
*
|
||||
* Two things live here, both free of the shell so they can be tested on their own
|
||||
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
|
||||
* estimator that turns its coarse, late readings into a usable position.
|
||||
*
|
||||
* The API is the undocumented one behind studio.resi.io. It is not supported by
|
||||
* Resi and can change without notice; that is the price of there being no public
|
||||
* way to read cue points at all.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const RESI = 'https://central.resi.io';
|
||||
|
||||
/* ── time ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
|
||||
function positionToMs(position) {
|
||||
if (typeof position !== 'string') return null;
|
||||
const parts = position.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const hh = Number(parts[0]);
|
||||
const mm = Number(parts[1]);
|
||||
const ss = Number(parts[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
|
||||
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
|
||||
}
|
||||
|
||||
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
|
||||
function msToPosition(total) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
|
||||
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
|
||||
}
|
||||
|
||||
/* ── the playhead estimator ────────────────────────────────────────────── */
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
* cadence, and each reading is already a little stale when it appears. Shown raw,
|
||||
* a countdown built on it sits seconds behind and moves in lurches.
|
||||
*
|
||||
* So every reading is kept as *media time minus wall time*. While playback runs at
|
||||
* 1x that difference is constant, and the LARGEST recent value is the reading that
|
||||
* reached us with the least delay — the one to trust. The estimate is then simply
|
||||
* "now plus that offset", which advances smoothly by itself and cannot be dragged
|
||||
* backwards by a slow round trip.
|
||||
*
|
||||
* `now` is injected so the tests can drive a fake clock.
|
||||
*/
|
||||
function createPlayhead(options) {
|
||||
const opts = options || {};
|
||||
const trimMs = Number(opts.trimMs) || 0;
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
|
||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||
let gaps = []; // how long between the playhead actually moving
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
let lastEst;
|
||||
let seeked = false;
|
||||
|
||||
function note(mediaMs) {
|
||||
if (!Number.isFinite(mediaMs)) return;
|
||||
const t = now();
|
||||
if (mediaMs !== lastMedia) {
|
||||
const off = mediaMs - t;
|
||||
// Anything inside the measured noise floor is noise; only a move clearly
|
||||
// beyond it is a real seek. Measured rather than assumed, and remembered
|
||||
// across a reset so the first reading after one is not judged blind.
|
||||
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
|
||||
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
|
||||
if (base !== undefined) {
|
||||
const delta = off - base;
|
||||
if (Math.abs(delta) > threshold) {
|
||||
offsets = []; // the window describes where we used to be
|
||||
seeked = true;
|
||||
// Only abandon the no-going-backwards guard for a move big enough that
|
||||
// snapping is obviously right; a marginal call must not make the
|
||||
// displayed playhead lurch backwards.
|
||||
if (Math.abs(delta) > 10000) lastEst = undefined;
|
||||
} else if (delta < 0) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) {
|
||||
const gap = t - lastNewAt;
|
||||
// The gap either side of a pause is not Resi's cadence — recording it
|
||||
// would inflate the estimate and make the next pause slow to notice.
|
||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
}
|
||||
}
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
}
|
||||
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
|
||||
// Always keep the newest reading even once it ages out. If the window empties
|
||||
// during a long pause there is nothing left to compare against, and the resume
|
||||
// reads as an ordinary reading instead of the discontinuity it is.
|
||||
offsets = kept.length ? kept : offsets.slice(-1);
|
||||
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
|
||||
const t = now();
|
||||
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
// Only call it paused once the playhead has been still for clearly longer than
|
||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||
// countdown that freezes mid-service is the worse failure of the two.
|
||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
}
|
||||
let est = t + base + trimMs;
|
||||
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
|
||||
lastEst = est;
|
||||
return { estMs: est, playing: true, cadenceMs };
|
||||
}
|
||||
|
||||
/** True once per detected discontinuity — the caller re-reads the cue list. */
|
||||
function takeSeek() {
|
||||
const was = seeked;
|
||||
seeked = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
return { note, read, takeSeek };
|
||||
}
|
||||
|
||||
/* ── the Resi Central API ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
|
||||
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
|
||||
*/
|
||||
function createResiClient(options) {
|
||||
const opts = options || {};
|
||||
const doFetch = opts.fetch || globalThis.fetch;
|
||||
let cookieHeader = '';
|
||||
let token = '';
|
||||
let loggedInAt = 0;
|
||||
let customerId = '';
|
||||
let cacheBust = true;
|
||||
|
||||
function headers() {
|
||||
const h = { Accept: 'application/json' };
|
||||
if (cookieHeader) h.Cookie = cookieHeader;
|
||||
if (token) h.Authorization = `X-Bearer ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
for (const raw of setCookies) {
|
||||
const first = String(raw).split(';')[0];
|
||||
const eq = first.indexOf('=');
|
||||
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
|
||||
}
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = Date.now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await login();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function venues() {
|
||||
await ensureSession();
|
||||
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
|
||||
}
|
||||
|
||||
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
|
||||
* one — and if the service objects to the parameter, stop sending it. */
|
||||
async function players(venueUuid) {
|
||||
await ensureSession();
|
||||
const path = `/api_v2.svc/users/${venueUuid}/players`;
|
||||
if (cacheBust) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
return (await get(path)) || [];
|
||||
}
|
||||
|
||||
async function event(eventId) {
|
||||
await ensureSession();
|
||||
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
|
||||
}
|
||||
|
||||
async function cues(profileId, eventId) {
|
||||
await ensureSession();
|
||||
const raw = (await get(
|
||||
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
|
||||
return raw
|
||||
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
|
||||
.filter((c) => c.ms !== null)
|
||||
.sort((a, b) => a.ms - b.ms);
|
||||
}
|
||||
|
||||
return {
|
||||
login,
|
||||
ensureSession,
|
||||
venues,
|
||||
players,
|
||||
event,
|
||||
cues,
|
||||
get customerId() { return customerId; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The cue after `estMs`, and the one before it. */
|
||||
function surroundingCues(cues, estMs) {
|
||||
let prev = null;
|
||||
let next = null;
|
||||
for (const c of cues || []) {
|
||||
if (c.ms > estMs) { next = c; break; }
|
||||
prev = c;
|
||||
}
|
||||
return { prev, next };
|
||||
}
|
||||
|
||||
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the Resi playhead estimator.
|
||||
*
|
||||
* node modules/resi-cues/resi.test.js
|
||||
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure.
|
||||
*
|
||||
* The estimator's whole job is to be right about a feed that is coarse, late and
|
||||
* irregular, so the fixture is a simulated Resi rather than a list of values: a
|
||||
* decoder playing at 1x, and an API that republishes its position every so often,
|
||||
* already stale, over a round trip that varies. Nine profiles span what the real
|
||||
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
|
||||
* against one, three separate breakages of this code go unnoticed.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
/* ── the simulated world ───────────────────────────────────────────────── */
|
||||
|
||||
function makeWorld({ cadence, lag, jitter, net }) {
|
||||
let seed = 12345;
|
||||
const rnd = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const w = {
|
||||
t: 1700000000000,
|
||||
media: 8 * 60000 + 48000,
|
||||
playing: true,
|
||||
published: 0,
|
||||
lastPublish: 0,
|
||||
nextCadence: cadence,
|
||||
};
|
||||
w.published = w.media - lag;
|
||||
w.lastPublish = w.t;
|
||||
w.advance = (ms) => {
|
||||
for (let i = 0; i < ms; i += 100) {
|
||||
w.t += 100;
|
||||
if (w.playing) w.media += 100;
|
||||
if (w.t - w.lastPublish >= w.nextCadence) {
|
||||
w.lastPublish = w.t;
|
||||
w.published = w.media - lag;
|
||||
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
|
||||
}
|
||||
}
|
||||
};
|
||||
// A poll reads what Resi had when the request left, and the round trip costs time.
|
||||
w.poll = () => {
|
||||
const body = w.published;
|
||||
if (net) w.advance(100 + Math.round(rnd() * net));
|
||||
return body;
|
||||
};
|
||||
return w;
|
||||
}
|
||||
|
||||
const CUES = [
|
||||
{ name: 'Welcome', ms: 5 * 60000 },
|
||||
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
|
||||
{ name: 'Message', ms: 41 * 60000 },
|
||||
{ name: 'Closing', ms: 45 * 60000 },
|
||||
];
|
||||
|
||||
function runProfile(cadence, lag, jitter, net) {
|
||||
const w = makeWorld({ cadence, lag, jitter, net });
|
||||
const head = createPlayhead({ now: () => w.t });
|
||||
const tol = lag + 1200 + jitter + net * 0.35;
|
||||
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
|
||||
|
||||
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
|
||||
|
||||
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
|
||||
seconds(40);
|
||||
let worstEst = 0;
|
||||
let worstRaw = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const { estMs } = second();
|
||||
worstEst = Math.max(worstEst, w.media - estMs);
|
||||
worstRaw = Math.max(worstRaw, w.media - w.published);
|
||||
}
|
||||
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
|
||||
if (cadence >= 2000) {
|
||||
assert.ok(worstRaw >= cadence * 0.4,
|
||||
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
|
||||
}
|
||||
assert.ok(head.read().playing, 'should read as playing');
|
||||
});
|
||||
|
||||
test(`${label}: ticks every second and never runs backwards`, () => {
|
||||
let prev = head.read().estMs;
|
||||
let stalls = 0;
|
||||
let backwards = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const { estMs } = second();
|
||||
if (estMs === prev) stalls++;
|
||||
if (estMs < prev) backwards++;
|
||||
prev = estMs;
|
||||
}
|
||||
assert.strictEqual(stalls, 0, 'the playhead stalled');
|
||||
assert.strictEqual(backwards, 0, 'the playhead went backwards');
|
||||
});
|
||||
|
||||
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
|
||||
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
|
||||
let reported = false;
|
||||
for (let i = 0; i < 60 && !reported; i++) {
|
||||
second();
|
||||
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
|
||||
}
|
||||
assert.ok(reported, 'the jump was never picked up');
|
||||
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
|
||||
const { estMs } = head.read();
|
||||
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
|
||||
`resynced ${Math.round(w.media - estMs)}ms off`);
|
||||
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
|
||||
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
|
||||
const after = seconds(15);
|
||||
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
|
||||
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
|
||||
});
|
||||
|
||||
test(`${label}: follows a seek backwards`, () => {
|
||||
w.media = 6 * 60000;
|
||||
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
|
||||
const { estMs } = head.read();
|
||||
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
|
||||
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
|
||||
});
|
||||
|
||||
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
|
||||
w.playing = false;
|
||||
const stoppedAt = w.media;
|
||||
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
|
||||
+ cadence + jitter + 6000) / 1000);
|
||||
let r = seconds(wait);
|
||||
assert.strictEqual(r.playing, false, 'did not notice the pause');
|
||||
const held = r.estMs;
|
||||
r = seconds(20);
|
||||
assert.strictEqual(r.estMs, held, 'kept counting while paused');
|
||||
|
||||
w.playing = true;
|
||||
r = seconds(25);
|
||||
assert.strictEqual(r.playing, true, 'did not notice the resume');
|
||||
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
|
||||
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
|
||||
'the frozen position crept instead of stopping');
|
||||
});
|
||||
|
||||
test(`${label}: survives a pause longer than the offset window`, () => {
|
||||
w.playing = false;
|
||||
let r = seconds(150);
|
||||
assert.strictEqual(r.playing, false, 'should still read as paused');
|
||||
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
|
||||
w.playing = true;
|
||||
head.takeSeek();
|
||||
r = seconds(30);
|
||||
assert.strictEqual(r.playing, true, 'did not recover');
|
||||
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
|
||||
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the plain bits ────────────────────────────────────────────────────── */
|
||||
|
||||
test('positionToMs reads Resi positions, and rejects anything else', () => {
|
||||
assert.strictEqual(positionToMs('00:00:00.000'), 0);
|
||||
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
|
||||
assert.strictEqual(positionToMs('00:00:01'), 1000);
|
||||
assert.strictEqual(positionToMs(''), null);
|
||||
assert.strictEqual(positionToMs('garbage'), null);
|
||||
assert.strictEqual(positionToMs(null), null);
|
||||
assert.strictEqual(positionToMs(undefined), null);
|
||||
});
|
||||
|
||||
test('msToPosition round-trips', () => {
|
||||
for (const ms of [0, 1500, 3723500, 36000000]) {
|
||||
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
|
||||
}
|
||||
});
|
||||
|
||||
test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
|
||||
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
|
||||
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
|
||||
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
|
||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||
});
|
||||
|
||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
|
||||
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
|
||||
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
|
||||
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
|
||||
];
|
||||
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,356 @@
|
||||
/* Resi Cues — server entry.
|
||||
*
|
||||
* Follows one Resi decoder and works out how long until its next cue point, then
|
||||
* offers that two ways:
|
||||
*
|
||||
* GET /state → snapshot for this module's tile
|
||||
* GET /stream → SSE, `state` events on every change
|
||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||
* GET /decoders → optionsRoute for the admin page's decoder picker
|
||||
*
|
||||
* Both the cue list and the playhead come from Resi's cloud rather than the
|
||||
* decoder: the decoder reports its position upstream and central.resi.io hands it
|
||||
* back. Nothing is installed at the decoder end, and nothing here talks to the
|
||||
* decoder directly.
|
||||
*
|
||||
* Why the playhead is estimated rather than repeated is explained in resi.js.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
|
||||
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function sseHead(res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
}
|
||||
|
||||
function writeAll(set, chunk) {
|
||||
for (const res of set) {
|
||||
try {
|
||||
res.write(chunk);
|
||||
} catch {
|
||||
set.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the tile and the Timers module see before anything has been configured. */
|
||||
function emptyState(message) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: message || 'Not configured',
|
||||
decoder: '',
|
||||
event: '',
|
||||
playing: false,
|
||||
estMs: null,
|
||||
estAt: Date.now(),
|
||||
cadenceMs: 0,
|
||||
next: null,
|
||||
prev: null,
|
||||
cues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTimers(now) {
|
||||
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
|
||||
}
|
||||
|
||||
let current = null; // the live handle, shared with routes()
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const username = String(config.username || '').trim();
|
||||
const password = String(config.password || '');
|
||||
const wanted = String(config.player || '').trim();
|
||||
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
|
||||
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
|
||||
|
||||
const stateStreams = new Set();
|
||||
const timerStreams = new Set();
|
||||
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
|
||||
let timer = null;
|
||||
let heartbeat = null;
|
||||
let stopped = false;
|
||||
|
||||
const resi = createResiClient({ username, password });
|
||||
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
|
||||
|
||||
let venueUuid = '';
|
||||
let hardwareId = '';
|
||||
let decoderName = '';
|
||||
let eventId = '';
|
||||
let profileId = '';
|
||||
let eventName = '';
|
||||
let cues = [];
|
||||
let lastCueLoad = 0;
|
||||
let inFlight = false;
|
||||
|
||||
/* — what the two consumers see — */
|
||||
|
||||
function publishState(next) {
|
||||
const before = JSON.stringify(state);
|
||||
state = next;
|
||||
if (JSON.stringify(state) === before) return;
|
||||
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Timers module extrapolates from `startedAt` and its own clock, so this
|
||||
* only has to change when a timer changes *structurally* — a new cue, a pause,
|
||||
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
|
||||
* for nothing.
|
||||
*/
|
||||
function timersSignature(snap) {
|
||||
return JSON.stringify(snap.timers.map(
|
||||
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
|
||||
}
|
||||
|
||||
let lastTimerSig = '';
|
||||
function publishTimers() {
|
||||
const snap = timersSnapshot(Date.now());
|
||||
const sig = timersSignature(snap);
|
||||
if (sig === lastTimerSig) return;
|
||||
lastTimerSig = sig;
|
||||
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
}
|
||||
|
||||
function timersSnapshot(now) {
|
||||
const timers = [];
|
||||
if (state.estMs !== null && state.estMs !== undefined) {
|
||||
const est = state.estMs + (state.playing ? now - state.estAt : 0);
|
||||
if (state.next) {
|
||||
const remainingMs = Math.max(0, state.next.ms - est);
|
||||
// The countdown "runs" from the cue before it, which is what gives the
|
||||
// consumer something to colour progress against.
|
||||
const from = state.prev ? state.prev.ms : 0;
|
||||
timers.push({
|
||||
id: 'next-cue',
|
||||
label: state.next.name || 'Next cue',
|
||||
kind: 'countdown',
|
||||
state: state.playing ? 'running' : 'paused',
|
||||
startedAt: now - Math.max(0, est - from),
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
targetMs: Math.max(1000, state.next.ms - from),
|
||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
||||
detail: state.event || '',
|
||||
});
|
||||
}
|
||||
timers.push({
|
||||
id: 'playhead',
|
||||
label: 'Playhead',
|
||||
kind: 'elapsed',
|
||||
state: state.playing ? 'running' : 'paused',
|
||||
startedAt: now - Math.max(0, est),
|
||||
elapsedMs: Math.max(0, est),
|
||||
remainingMs: null,
|
||||
targetMs: null,
|
||||
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
|
||||
detail: state.prev ? `After ${state.prev.name}` : '',
|
||||
});
|
||||
}
|
||||
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
|
||||
}
|
||||
|
||||
/* — finding the decoder — */
|
||||
|
||||
async function locate() {
|
||||
const venues = await resi.venues();
|
||||
for (const v of venues) {
|
||||
const players = await resi.players(v.uuid);
|
||||
for (const p of players) {
|
||||
if (wanted ? p.playerHardwareId === wanted : true) {
|
||||
venueUuid = v.uuid;
|
||||
hardwareId = p.playerHardwareId;
|
||||
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* — the loop — */
|
||||
|
||||
async function tick() {
|
||||
if (inFlight || stopped) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
if (!hardwareId && !(await locate())) {
|
||||
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const players = await resi.players(venueUuid);
|
||||
const me = players.find((p) => p.playerHardwareId === hardwareId);
|
||||
if (!me) {
|
||||
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
|
||||
return;
|
||||
}
|
||||
|
||||
head.note(positionToMs(me.position));
|
||||
|
||||
if (me.eventId && me.eventId !== eventId) {
|
||||
eventId = me.eventId;
|
||||
const ev = (await resi.event(eventId)) || {};
|
||||
profileId = ev.eventProfileId || '';
|
||||
eventName = ev.name || '';
|
||||
cues = profileId ? await resi.cues(profileId, eventId) : [];
|
||||
lastCueLoad = Date.now();
|
||||
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
|
||||
} else if (!me.eventId) {
|
||||
eventId = '';
|
||||
profileId = '';
|
||||
eventName = '';
|
||||
cues = [];
|
||||
}
|
||||
|
||||
const seeked = head.takeSeek();
|
||||
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
|
||||
lastCueLoad = Date.now();
|
||||
const fresh = await resi.cues(profileId, eventId);
|
||||
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
|
||||
log(`cue list changed — ${fresh.length} cue(s)`);
|
||||
cues = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
const { estMs, playing, cadenceMs } = head.read();
|
||||
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
|
||||
publishState({
|
||||
status: 'ok',
|
||||
message: '',
|
||||
decoder: decoderName,
|
||||
event: eventName,
|
||||
playing,
|
||||
estMs,
|
||||
estAt: Date.now(),
|
||||
cadenceMs,
|
||||
next,
|
||||
prev,
|
||||
cues,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err && err.message) || String(err);
|
||||
log(`poll failed: ${message}`);
|
||||
publishState({ ...state, status: 'error', message });
|
||||
} finally {
|
||||
inFlight = false;
|
||||
publishTimers();
|
||||
}
|
||||
}
|
||||
|
||||
if (username && password) {
|
||||
timer = setInterval(() => { void tick(); }, pollMs);
|
||||
void tick();
|
||||
} else {
|
||||
log('no Resi login configured — idle');
|
||||
}
|
||||
heartbeat = setInterval(() => {
|
||||
writeAll(stateStreams, ': ping\n\n');
|
||||
writeAll(timerStreams, ': ping\n\n');
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
current = {
|
||||
get state() { return state; },
|
||||
timersSnapshot,
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer) clearInterval(timer);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
for (const res of [...stateStreams, ...timerStreams]) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
stateStreams.clear();
|
||||
timerStreams.clear();
|
||||
if (current && current.stop === this.stop) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
|
||||
if (state.status === 'ok') {
|
||||
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
|
||||
}
|
||||
if (state.status === 'error') return { status: 'error', message: state.message };
|
||||
return { status: 'connecting', message: state.message };
|
||||
},
|
||||
};
|
||||
return current;
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, current ? current.state : emptyState('Module restarting'));
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
sseHead(res);
|
||||
const snap = current ? current.state : emptyState('Module restarting');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
if (!current) return res.end();
|
||||
current.stateStreams.add(res);
|
||||
req.on('close', () => current && current.stateStreams.delete(res));
|
||||
},
|
||||
|
||||
'GET /timers': (req, res) => {
|
||||
const now = Date.now();
|
||||
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
|
||||
},
|
||||
|
||||
'GET /timers/stream': (req, res) => {
|
||||
sseHead(res);
|
||||
const now = Date.now();
|
||||
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
|
||||
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
if (!current) return res.end();
|
||||
current.timerStreams.add(res);
|
||||
req.on('close', () => current && current.timerStreams.delete(res));
|
||||
},
|
||||
|
||||
// The admin page's decoder picker. A live call, because it is how the decoder
|
||||
// gets chosen in the first place — there is nothing cached to answer from.
|
||||
'GET /decoders': async (req, res) => {
|
||||
if (!config.username || !config.password) {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
const options = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
options.push({
|
||||
value: p.playerHardwareId,
|
||||
label: `${p.userName || p.playerHardwareId}`,
|
||||
group: v.userName || v.name || 'Venue',
|
||||
});
|
||||
}
|
||||
}
|
||||
json(res, 200, { options });
|
||||
} catch (err) {
|
||||
log(`decoder list failed: ${(err && err.message) || err}`);
|
||||
json(res, 502, { options: [], error: (err && err.message) || String(err) });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Server-entry tests for Resi Cues.
|
||||
*
|
||||
* node modules/resi-cues/server.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. Resi is
|
||||
* replaced with a canned account — a venue, a decoder on an event, four cue
|
||||
* points — so this covers the parts `resi.test.js` cannot: signing in, finding
|
||||
* the decoder, following the event, and the two snapshots other things read
|
||||
* (`/state` for the tile, `/timers` for the Timers module).
|
||||
*
|
||||
* Nothing here reaches the network, and the poll interval is captured rather
|
||||
* than run, so the test drives the loop a tick at a time.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
/* ── a canned Resi account ─────────────────────────────────────────────── */
|
||||
|
||||
const CUES = [
|
||||
{ name: 'Welcome', position: '00:05:00.000' },
|
||||
{ name: 'Campus Host', position: '00:12:30.000' },
|
||||
{ name: 'Message', position: '00:41:00.000' },
|
||||
];
|
||||
|
||||
const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 };
|
||||
|
||||
function reply(body, extra) {
|
||||
return Object.assign({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => body,
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
world.logins++;
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' });
|
||||
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]);
|
||||
if (path === '/api_v2.svc/users/V1/players') {
|
||||
world.playerCalls++;
|
||||
return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]);
|
||||
}
|
||||
if (path === '/api/v3/customers/CUST/events/EV1') {
|
||||
return reply({ name: 'Sunday 9am', eventProfileId: 'P1' });
|
||||
}
|
||||
if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES);
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
};
|
||||
|
||||
/* ── the shell, as much of it as the module touches ────────────────────── */
|
||||
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
const realClearInterval = globalThis.clearInterval;
|
||||
let tickFn = null;
|
||||
globalThis.setInterval = (fn, ms) => {
|
||||
if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one
|
||||
return { fn, ms };
|
||||
};
|
||||
globalThis.clearInterval = () => {};
|
||||
|
||||
const server = require('./server');
|
||||
const logged = [];
|
||||
const handle = server.init({
|
||||
config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 },
|
||||
log: (m) => logged.push(m),
|
||||
});
|
||||
|
||||
/** Let the poll's promise chain finish; nothing here waits on real time. */
|
||||
async function settle() {
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
function collect(route, req) {
|
||||
const res = {
|
||||
code: 0, headers: null, body: '',
|
||||
writeHead(code, headers) { this.code = code; this.headers = headers; },
|
||||
write(chunk) { this.body += chunk; },
|
||||
end(chunk) { if (chunk) this.body += chunk; },
|
||||
};
|
||||
route(req || { on() {} }, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await settle();
|
||||
|
||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||
|
||||
test('signs in once and finds the configured decoder', () => {
|
||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.status, 'ok', state.message);
|
||||
assert.strictEqual(state.decoder, 'Main — Lobby');
|
||||
assert.strictEqual(state.event, 'Sunday 9am');
|
||||
assert.strictEqual(state.cues.length, 3);
|
||||
});
|
||||
|
||||
test('works out the cue either side of the playhead', () => {
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Campus Host');
|
||||
assert.strictEqual(state.prev.name, 'Welcome');
|
||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||
});
|
||||
|
||||
test('/state answers the same snapshot as the stream', () => {
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||
});
|
||||
|
||||
test('/stream opens with an event so a new tile paints at once', () => {
|
||||
const res = collect(routes['GET /stream']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||
assert.ok(res.body.includes('retry: 3000'), 'should set a retry');
|
||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||
});
|
||||
|
||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.source, 'resi-cues');
|
||||
assert.strictEqual(snap.label, 'Resi');
|
||||
assert.ok(snap.updatedAt > 0);
|
||||
const [next, playhead] = snap.timers;
|
||||
|
||||
assert.strictEqual(next.id, 'next-cue');
|
||||
assert.strictEqual(next.kind, 'countdown');
|
||||
assert.strictEqual(next.state, 'running');
|
||||
assert.strictEqual(next.label, 'Campus Host');
|
||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||
assert.strictEqual(next.targetMs, 450000);
|
||||
assert.strictEqual(next.status.text, 'Campus Host');
|
||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||
|
||||
assert.strictEqual(playhead.id, 'playhead');
|
||||
assert.strictEqual(playhead.kind, 'elapsed');
|
||||
assert.strictEqual(playhead.remainingMs, null);
|
||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||
});
|
||||
|
||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||
tickFn();
|
||||
await settle();
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Message');
|
||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
||||
});
|
||||
|
||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
world.eventId = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
assert.strictEqual(handle.state.next, null);
|
||||
assert.deepStrictEqual(handle.state.cues, []);
|
||||
assert.strictEqual(handle.state.event, '');
|
||||
});
|
||||
|
||||
test('health speaks about the upstream, not about itself', () => {
|
||||
const h = handle.health();
|
||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||
});
|
||||
|
||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
const before = world.logins;
|
||||
const idle = server.init({ config: {}, log: () => {} });
|
||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||
assert.strictEqual(idle.health().status, 'error');
|
||||
assert.ok(/login/i.test(idle.health().message), idle.health().message);
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||
});
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with
|
||||
the shell's CSS variables only.
|
||||
|
||||
The countdown is sized in cqmin so it fills whatever the tile is given: the
|
||||
same module is a small strip on a busy dashboard and a wall panel on its own. */
|
||||
|
||||
[data-module="resi-cues"] {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35em;
|
||||
padding: 2%;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-label {
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.6rem, 5cqmin, 1rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-cue {
|
||||
color: var(--text);
|
||||
font-size: clamp(0.85rem, 11cqmin, 2.6rem);
|
||||
font-weight: 650;
|
||||
line-height: 1.05;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-count {
|
||||
color: var(--accent);
|
||||
font-size: clamp(1.4rem, 30cqmin, 9rem);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); }
|
||||
|
||||
/* Held rather than counting: the digits are still true, they just are not moving. */
|
||||
[data-module="resi-cues"] .resi-count.is-held,
|
||||
[data-module="resi-cues"] .resi-foot-item.is-held {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.2em 0.9em;
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.6rem, 5cqmin, 0.95rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list {
|
||||
list-style: none;
|
||||
margin: 0.4em 0 0;
|
||||
padding: 0.4em 0 0;
|
||||
width: 100%;
|
||||
max-width: 28em;
|
||||
border-top: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
font-size: clamp(0.6rem, 4.5cqmin, 0.95rem);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
padding: 0.12em 0.2em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); }
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row.is-next {
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; }
|
||||
|
||||
[data-module="resi-cues"] .resi-list-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6%;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/* Service Times — the board.
|
||||
*
|
||||
* One card per campus, one row per service, and a mark on any service that
|
||||
* production has to treat differently this week. Everything is drawn from the
|
||||
* server's SSE state so every booth sees the same marks at the same moment;
|
||||
* nothing here talks to the website.
|
||||
*
|
||||
* Marking lives in the tile body, not the title bar, because the bar folds
|
||||
* away in fullscreen and on an auto-hiding dashboard — a control an operator
|
||||
* needs mid-service may not disappear with it.
|
||||
*/
|
||||
|
||||
const STATUSES = [
|
||||
{ value: 'ok', label: 'Clear', short: '' },
|
||||
{ value: 'issue', label: 'Issue', short: 'Issue' },
|
||||
{ value: 'replay', label: 'Replay', short: 'Replay' },
|
||||
{ value: 'skip', label: 'Do not use', short: 'Do not use' },
|
||||
];
|
||||
|
||||
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
|
||||
+ '<path d="M13.6 1.9v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const ICON_CLEAR = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3 4h10M6.5 4V2.8h3V4M4.4 4l.7 8.4a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9L11.6 4"'
|
||||
+ ' fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const ICON_SMALLER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
const ICON_BIGGER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
|
||||
const TEXT_MIN = 0.8;
|
||||
const TEXT_MAX = 1.8;
|
||||
const TEXT_STEP = 0.1;
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let state = null;
|
||||
let editing = null; // { campus, service } while the editor is open
|
||||
let pendingRender = false; // a state arrived while the editor was open
|
||||
let board = null;
|
||||
let closeEditor = null;
|
||||
|
||||
const settings = () => moduleApi.instanceSettings;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The campuses this tile is asked to show; no choice made means all of them. */
|
||||
function visibleCampuses() {
|
||||
const all = state?.campuses || [];
|
||||
const chosen = settings().campuses;
|
||||
if (!Array.isArray(chosen) || !chosen.length) return all;
|
||||
return all.filter((campus) => chosen.includes(campus.slug));
|
||||
}
|
||||
|
||||
function textScale() {
|
||||
const saved = Number(settings().textSize);
|
||||
return Number.isFinite(saved) ? Math.min(TEXT_MAX, Math.max(TEXT_MIN, saved)) : 1;
|
||||
}
|
||||
|
||||
function serviceLabel(service) {
|
||||
return service.label ? `${service.label} ${service.text}` : service.text;
|
||||
}
|
||||
|
||||
/** Every service of the campus a resi campus takes its feed from. */
|
||||
function sourceServices(campus) {
|
||||
if (!campus.source) return [];
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
return origin ? origin.services : [];
|
||||
}
|
||||
|
||||
/** The mark on the origin service a resi service is taking, if any. */
|
||||
function upstreamTrouble(campus, service) {
|
||||
const sourceId = service.mark?.source;
|
||||
if (!sourceId || !campus.source) return null;
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
const upstream = origin?.services.find((s) => s.id === sourceId);
|
||||
if (!upstream?.mark || upstream.mark.status === 'ok') return null;
|
||||
return { origin, upstream };
|
||||
}
|
||||
|
||||
/** Resi services pointed at this broadcast service. */
|
||||
function dependentsOf(campus, service) {
|
||||
if (campus.role !== 'broadcast') return [];
|
||||
const out = [];
|
||||
for (const other of state?.campuses || []) {
|
||||
if (other.source !== campus.slug) continue;
|
||||
for (const theirs of other.services) {
|
||||
if (theirs.mark?.source === service.id) out.push({ campus: other, service: theirs });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── drawing ──────────────────────────────────────────────────────── */
|
||||
|
||||
function renderServiceRow(campus, service) {
|
||||
const opts = settings();
|
||||
const row = el('button', 'st-row');
|
||||
row.type = 'button';
|
||||
const status = service.mark?.status || 'ok';
|
||||
if (status !== 'ok') row.classList.add(`st-${status}`);
|
||||
|
||||
const time = el('span', 'st-time', serviceLabel(service));
|
||||
row.appendChild(time);
|
||||
|
||||
if (opts.showDay && service.day) row.appendChild(el('span', 'st-day', service.day));
|
||||
|
||||
const chips = el('span', 'st-chips');
|
||||
const statusDef = STATUSES.find((s) => s.value === status);
|
||||
if (statusDef?.short) chips.appendChild(el('span', `st-chip st-chip-${status}`, statusDef.short));
|
||||
|
||||
// A resi service says which of the origin's services it is taking…
|
||||
if (opts.showSource && campus.role === 'resi' && service.mark?.source) {
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
const upstream = origin?.services.find((s) => s.id === service.mark.source);
|
||||
if (upstream) chips.appendChild(el('span', 'st-chip st-chip-source', `← ${upstream.text}`));
|
||||
}
|
||||
// …and turns red when that service is the one in trouble.
|
||||
const trouble = upstreamTrouble(campus, service);
|
||||
if (trouble) {
|
||||
row.classList.add('st-affected');
|
||||
const what = STATUSES.find((s) => s.value === trouble.upstream.mark.status)?.short || 'marked';
|
||||
chips.appendChild(el('span', 'st-chip st-chip-skip', `source ${what.toLowerCase()}`));
|
||||
}
|
||||
|
||||
// A broadcast service says who is riding on it.
|
||||
if (opts.showDependents && campus.role === 'broadcast') {
|
||||
const riders = dependentsOf(campus, service);
|
||||
if (riders.length && status !== 'ok') {
|
||||
chips.appendChild(el('span', 'st-chip st-chip-warn',
|
||||
`affects ${riders.map((r) => r.campus.name).join(', ')}`));
|
||||
} else if (riders.length) {
|
||||
chips.appendChild(el('span', 'st-chip st-chip-quiet', `→ ${riders.length}`));
|
||||
}
|
||||
}
|
||||
row.appendChild(chips);
|
||||
|
||||
if (opts.showNotes && service.mark?.note) {
|
||||
row.appendChild(el('span', 'st-note', service.mark.note));
|
||||
}
|
||||
|
||||
row.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openEditor(campus, service, row);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderCampus(campus) {
|
||||
const opts = settings();
|
||||
const card = el('section', 'st-campus');
|
||||
if (campus.role === 'broadcast') card.classList.add('st-is-broadcast');
|
||||
|
||||
const head = el('header', 'st-campus-head');
|
||||
head.appendChild(el('h3', 'st-campus-name', campus.name));
|
||||
|
||||
if (opts.showRole) {
|
||||
if (campus.role === 'broadcast') {
|
||||
head.appendChild(el('span', 'st-role st-role-broadcast', 'Broadcast'));
|
||||
} else if (campus.source) {
|
||||
head.appendChild(el('span', 'st-role', campus.sourceMissing
|
||||
? `resi ← ${campus.sourceName} (not listed)`
|
||||
: `resi ← ${campus.sourceName}`));
|
||||
} else {
|
||||
head.appendChild(el('span', 'st-role st-role-unset', 'no resi source'));
|
||||
}
|
||||
}
|
||||
if (campus.manual) head.appendChild(el('span', 'st-role st-role-manual', 'manual'));
|
||||
card.appendChild(head);
|
||||
|
||||
if (opts.showDependents && campus.role === 'broadcast' && campus.dependents?.length) {
|
||||
card.appendChild(el('p', 'st-feeds', `feeds ${campus.dependents.map((d) => d.name).join(', ')}`));
|
||||
}
|
||||
|
||||
if (!campus.services.length) {
|
||||
card.appendChild(el('p', 'st-empty', campus.error || 'No times published'));
|
||||
return card;
|
||||
}
|
||||
const list = el('div', 'st-rows');
|
||||
for (const service of campus.services) list.appendChild(renderServiceRow(campus, service));
|
||||
card.appendChild(list);
|
||||
return card;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (closeEditor) closeEditor();
|
||||
root.innerHTML = '';
|
||||
board = el('div', 'st-board');
|
||||
board.style.fontSize = `${textScale()}em`;
|
||||
root.appendChild(board);
|
||||
|
||||
if (!state) {
|
||||
board.appendChild(el('p', 'st-empty', 'Waiting for the server…'));
|
||||
return;
|
||||
}
|
||||
if (!state.configured) {
|
||||
board.appendChild(el('p', 'st-empty', 'No website address configured — set one in Admin.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const campuses = visibleCampuses();
|
||||
if (!campuses.length) {
|
||||
board.appendChild(el('p', 'st-empty', state.reachable
|
||||
? 'No campuses to show. Pick some in Admin, or in this tile’s settings.'
|
||||
: 'No service times yet.'));
|
||||
} else {
|
||||
const grid = el('div', settings().layout === 'list' ? 'st-grid st-grid-list' : 'st-grid');
|
||||
for (const campus of campuses) grid.appendChild(renderCampus(campus));
|
||||
board.appendChild(grid);
|
||||
}
|
||||
|
||||
if (!state.reachable && state.lastError) {
|
||||
board.appendChild(el('p', 'st-warn', `Website unreachable — ${state.lastError}. Showing the last times read.`));
|
||||
}
|
||||
if (settings().showUpdated && state.lastFetched) {
|
||||
board.appendChild(el('p', 'st-updated',
|
||||
`Times read ${new Date(state.lastFetched).toLocaleString([], { hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })}`));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||
|
||||
function openEditor(campus, service, anchor) {
|
||||
if (closeEditor) closeEditor();
|
||||
editing = { campus: campus.slug, service: service.id };
|
||||
|
||||
const panel = el('div', 'st-editor');
|
||||
panel.appendChild(el('div', 'st-editor-title', `${campus.name} — ${serviceLabel(service)}`));
|
||||
|
||||
const mark = service.mark || { status: 'ok', note: '', source: '' };
|
||||
let status = mark.status || 'ok';
|
||||
|
||||
const row = el('div', 'st-editor-status');
|
||||
const buttons = new Map();
|
||||
for (const def of STATUSES) {
|
||||
const button = el('button', 'st-pick', def.label);
|
||||
button.type = 'button';
|
||||
if (def.value === status) button.classList.add('active');
|
||||
button.addEventListener('click', () => {
|
||||
status = def.value;
|
||||
for (const [value, node] of buttons) node.classList.toggle('active', value === status);
|
||||
});
|
||||
buttons.set(def.value, button);
|
||||
row.appendChild(button);
|
||||
}
|
||||
panel.appendChild(row);
|
||||
|
||||
// Which of the origin's services this one is taking — the answer to
|
||||
// "which service, and where, can be altered".
|
||||
let sourceSelect = null;
|
||||
const origins = campus.role === 'resi' ? sourceServices(campus) : [];
|
||||
if (origins.length) {
|
||||
const wrap = el('label', 'st-editor-field');
|
||||
wrap.appendChild(el('span', 'st-editor-label', `Taking from ${campus.sourceName}`));
|
||||
sourceSelect = el('select', 'st-editor-input');
|
||||
const none = el('option', null, '— not set —');
|
||||
none.value = '';
|
||||
sourceSelect.appendChild(none);
|
||||
for (const candidate of origins) {
|
||||
const option = el('option', null, serviceLabel(candidate));
|
||||
option.value = candidate.id;
|
||||
if (candidate.id === mark.source) option.selected = true;
|
||||
sourceSelect.appendChild(option);
|
||||
}
|
||||
wrap.appendChild(sourceSelect);
|
||||
panel.appendChild(wrap);
|
||||
}
|
||||
|
||||
const noteWrap = el('label', 'st-editor-field');
|
||||
noteWrap.appendChild(el('span', 'st-editor-label', 'Note'));
|
||||
const note = el('input', 'st-editor-input');
|
||||
note.type = 'text';
|
||||
note.maxLength = 280;
|
||||
note.value = mark.note || '';
|
||||
note.placeholder = 'What production needs to know';
|
||||
noteWrap.appendChild(note);
|
||||
panel.appendChild(noteWrap);
|
||||
|
||||
const actions = el('div', 'st-editor-actions');
|
||||
const save = el('button', 'st-pick st-save', 'Save');
|
||||
save.type = 'button';
|
||||
const cancel = el('button', 'st-pick', 'Cancel');
|
||||
cancel.type = 'button';
|
||||
actions.append(save, cancel);
|
||||
panel.appendChild(actions);
|
||||
|
||||
async function commit() {
|
||||
const body = {
|
||||
campus: campus.slug,
|
||||
service: service.id,
|
||||
status,
|
||||
note: note.value.trim(),
|
||||
source: sourceSelect ? sourceSelect.value : (mark.source || ''),
|
||||
};
|
||||
close();
|
||||
try {
|
||||
const response = await moduleApi.fetch('/mark', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status}`);
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Could not save the mark: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
save.addEventListener('click', commit);
|
||||
cancel.addEventListener('click', () => close());
|
||||
note.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') close();
|
||||
});
|
||||
panel.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
function onOutside() { close(); }
|
||||
function onKey(event) { if (event.key === 'Escape') close(); }
|
||||
|
||||
function close() {
|
||||
if (closeEditor !== close) return;
|
||||
panel.remove();
|
||||
anchor?.classList.remove('st-open');
|
||||
root.removeEventListener('click', onOutside);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
closeEditor = null;
|
||||
editing = null;
|
||||
if (pendingRender) {
|
||||
pendingRender = false;
|
||||
render(); // closeEditor is already null, so this cannot re-enter
|
||||
}
|
||||
}
|
||||
|
||||
closeEditor = close;
|
||||
anchor?.classList.add('st-open');
|
||||
board.appendChild(panel);
|
||||
root.addEventListener('click', onOutside);
|
||||
document.addEventListener('keydown', onKey);
|
||||
note.focus();
|
||||
note.select();
|
||||
}
|
||||
|
||||
/* ── lifecycle ────────────────────────────────────────────────────── */
|
||||
|
||||
function applyState(next) {
|
||||
state = next;
|
||||
if (!state) {
|
||||
moduleApi.setStatus('connecting', 'Waiting for the server');
|
||||
} else if (!state.configured) {
|
||||
moduleApi.setStatus('error', 'No website address configured');
|
||||
} else if (!state.reachable) {
|
||||
moduleApi.setStatus('error', state.lastError
|
||||
? `Website unreachable — ${state.lastError}`
|
||||
: 'Website unreachable');
|
||||
} else if (state.lastError) {
|
||||
moduleApi.setStatus('connecting', state.lastError);
|
||||
} else {
|
||||
const count = (state.campuses || []).reduce((sum, c) => sum + c.services.length, 0);
|
||||
moduleApi.setStatus('ok', `${count} service(s) across ${(state.campuses || []).length} campus(es)`);
|
||||
}
|
||||
// Re-rendering would close an open editor mid-sentence, so hold the change
|
||||
// back while someone is typing — and draw it the moment they are done, or
|
||||
// the board would sit stale until the next change happened to arrive.
|
||||
if (editing) {
|
||||
pendingRender = true;
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const response = await moduleApi.fetch('/state');
|
||||
const body = await response.json();
|
||||
applyState(body.state);
|
||||
} catch {
|
||||
/* the stream will bring it */
|
||||
}
|
||||
}
|
||||
|
||||
function setTextSize(delta) {
|
||||
const next = Math.min(TEXT_MAX, Math.max(TEXT_MIN, textScale() + delta));
|
||||
moduleApi.saveInstanceSettings({ textSize: Number(next.toFixed(2)) });
|
||||
if (board) board.style.fontSize = `${next}em`;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Reading service times');
|
||||
render();
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_REFRESH,
|
||||
title: 'Refresh times',
|
||||
onClick: async () => {
|
||||
moduleApi.setStatus('connecting', 'Re-reading the website');
|
||||
try {
|
||||
const response = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (body.state) applyState(body.state);
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Refresh failed: ${err.message || err}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_CLEAR,
|
||||
title: 'Clear marks',
|
||||
onClick: async () => {
|
||||
const marked = (state?.campuses || []).reduce(
|
||||
(sum, campus) => sum + campus.services.filter((s) => s.mark).length, 0
|
||||
);
|
||||
if (!marked) return;
|
||||
// Marks are shared by every campus watching — never wipe them silently.
|
||||
if (!window.confirm(`Clear ${marked} mark${marked === 1 ? '' : 's'} for every campus?`)) return;
|
||||
try {
|
||||
await moduleApi.fetch('/clear', { method: 'POST' });
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Could not clear marks: ${err.message || err}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_SMALLER,
|
||||
title: 'Smaller text',
|
||||
onClick: () => setTextSize(-TEXT_STEP),
|
||||
});
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_BIGGER,
|
||||
title: 'Larger text',
|
||||
onClick: () => setTextSize(TEXT_STEP),
|
||||
});
|
||||
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() {
|
||||
// The shared connection was (re)made — we may have missed a change.
|
||||
loadState();
|
||||
},
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||
},
|
||||
events: {
|
||||
state(event) {
|
||||
try {
|
||||
applyState(JSON.parse(event.data));
|
||||
} catch { /* a truncated frame; the next one will be whole */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
loadState();
|
||||
},
|
||||
|
||||
stop() {
|
||||
pendingRender = false; // nothing to catch up on: the tile is going away
|
||||
if (closeEditor) closeEditor();
|
||||
stream?.close();
|
||||
stream = null;
|
||||
state = null;
|
||||
editing = null;
|
||||
board = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"id": "service-times",
|
||||
"name": "Service Times",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Sunday service times for every campus, marked up for replays and services production cannot use",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 6, "h": 5 },
|
||||
"configGroups": {
|
||||
"Website": {
|
||||
"help": "Service times are read from the campus pages at <site>/locations/<campus>/ and refreshed on their own, so the board follows the website whenever the times change. Campuses are discovered from the site's sitemap — tick the ones production is responsible for."
|
||||
},
|
||||
"Broadcast": {
|
||||
"help": "Which campuses originate the Sunday broadcast, and which campus each of the others takes its resi from. This is what lets the board show, when a service is marked unusable, every campus affected by it."
|
||||
},
|
||||
"Manual campuses": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A stopgap for a campus the website has not published yet. The website stays the source of truth: as soon as it carries a campus by this name, its times take over and the health line above says the line here can be removed."
|
||||
},
|
||||
"Marks": {
|
||||
"collapsed": true,
|
||||
"help": "Marks are shared by every tile and every browser, and survive a restart of ProdDash."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"siteUrl": {
|
||||
"type": "string",
|
||||
"label": "Website",
|
||||
"default": "https://waterschurch.org",
|
||||
"group": "Website",
|
||||
"help": "The church website's address. Campus pages are read from /locations/ underneath it."
|
||||
},
|
||||
"campuses": {
|
||||
"type": "multiselect",
|
||||
"label": "Campuses",
|
||||
"default": ["north-attleboro", "woonsocket", "southcoast", "apollo-beach"],
|
||||
"group": "Website",
|
||||
"optionsRoute": "/discovered",
|
||||
"help": "Leave every campus unticked to follow all of them."
|
||||
},
|
||||
"refreshMinutes": {
|
||||
"type": "number",
|
||||
"label": "Refresh (min)",
|
||||
"default": 360,
|
||||
"group": "Website",
|
||||
"help": "How often the website is re-read. Times change a few times a year, so this can be slow; a failed read retries every minute and the last good times stay on screen."
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "multiselect",
|
||||
"label": "Broadcast campuses",
|
||||
"default": ["north-attleboro", "apollo-beach"],
|
||||
"group": "Broadcast",
|
||||
"optionsRoute": "/campuses",
|
||||
"help": "The campuses the broadcast originates from."
|
||||
},
|
||||
"feeds": {
|
||||
"type": "text",
|
||||
"label": "Resi sources",
|
||||
"default": "woonsocket = north-attleboro\nsouthcoast = north-attleboro",
|
||||
"group": "Broadcast",
|
||||
"help": "One campus per line: campus = the broadcast campus it takes resi from. Names or slugs both work."
|
||||
},
|
||||
"manual": {
|
||||
"type": "text",
|
||||
"label": "Campuses and times",
|
||||
"default": "",
|
||||
"group": "Manual campuses",
|
||||
"help": "One campus per line: Name | Day | 9:00a, 11:00a. The day is optional and defaults to Sunday. Lines starting with # are ignored."
|
||||
},
|
||||
"retentionHours": {
|
||||
"type": "number",
|
||||
"label": "Marks clear after (h)",
|
||||
"default": 36,
|
||||
"group": "Marks",
|
||||
"help": "A mark disappears this long after it was made, so Saturday's and Sunday's notes are gone before the next week. Set 0 to keep marks until they are cleared by hand."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": { "collapsed": true, "columns": 2 }
|
||||
},
|
||||
"instanceSchema": {
|
||||
"campuses": {
|
||||
"type": "multiselect",
|
||||
"label": "Campuses",
|
||||
"optionsRoute": "/campuses",
|
||||
"columns": 2,
|
||||
"allByDefault": true
|
||||
},
|
||||
"layout": {
|
||||
"type": "select",
|
||||
"label": "Layout",
|
||||
"default": "cards",
|
||||
"options": [
|
||||
{ "value": "cards", "label": "Cards" },
|
||||
{ "value": "list", "label": "List" }
|
||||
]
|
||||
},
|
||||
"showDay": { "type": "boolean", "label": "Day", "default": true, "group": "Show" },
|
||||
"showRole": { "type": "boolean", "label": "Role", "default": true, "group": "Show" },
|
||||
"showSource": { "type": "boolean", "label": "Resi source", "default": true, "group": "Show" },
|
||||
"showDependents": { "type": "boolean", "label": "Campuses fed", "default": true, "group": "Show" },
|
||||
"showNotes": { "type": "boolean", "label": "Notes", "default": true, "group": "Show" },
|
||||
"showUpdated": { "type": "boolean", "label": "Updated line", "default": false, "group": "Show" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service Times — reading the church website.
|
||||
*
|
||||
* Pure functions, no I/O, so every shape below can be checked without the
|
||||
* network (parse.test.js). The server entry does the fetching.
|
||||
*
|
||||
* Every campus page is built from the same page-builder blocks: a "Times"
|
||||
* heading, the day in bold, then the service times under it, ending before
|
||||
* the "Get Involved" heading. Bounding the slice that way is what keeps the
|
||||
* footer's main-office hours out of the service list — and some campuses
|
||||
* (Apollo Beach) carry their own "Office Hours" block inside the Times
|
||||
* section, which is why STOP_HEADINGS ends the walk early as well.
|
||||
*
|
||||
* The four shapes seen in the wild, all handled by walkSection():
|
||||
*
|
||||
* Woonsocket / Apollo Beach Sunday → 9:00a, 11:00a
|
||||
* 9:00a & 11:00a
|
||||
* North Attleboro Sunday → 7:30a, 9:30a, 11:30a
|
||||
* 7:30a<br>9:30a<br>11:30a
|
||||
* Orlando Sundays → Welcome 10:00a,
|
||||
* Welcome 10:00am Start 10:30a
|
||||
* Start 10:30am
|
||||
*/
|
||||
|
||||
const WEEKDAYS = [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
];
|
||||
|
||||
/** Headings that end the service list wherever they turn up in the section. */
|
||||
const STOP_HEADINGS = /^(office\s*hours?|get\s*involved|location\s*pastor|contact|address|events?)\b/i;
|
||||
|
||||
/** A time: "9:00a", "11:30am", "7a", "10:00 a.m." — never "1 person". */
|
||||
const TIME_RE = /(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*(?:m\.?)?(?![a-z])/gi;
|
||||
|
||||
/** "9:00a-4:30p" — an hours range, not two services. */
|
||||
const RANGE_RE = /\d\s*(?::\d{2})?\s*[ap]\.?m?\.?\s*[-–—]\s*\d/i;
|
||||
|
||||
const NAMED_ENTITIES = {
|
||||
amp: '&', nbsp: ' ', quot: '"', apos: "'", lt: '<', gt: '>',
|
||||
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
|
||||
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
|
||||
};
|
||||
|
||||
function fromCodePoint(n) {
|
||||
try {
|
||||
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEntities(input) {
|
||||
return String(input == null ? '' : input)
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, dec) => fromCodePoint(parseInt(dec, 10)))
|
||||
.replace(/&([a-z]+);/gi, (whole, name) => {
|
||||
const value = NAMED_ENTITIES[name.toLowerCase()];
|
||||
return value === undefined ? whole : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML → the lines a reader would see. Block ends and <br> become line
|
||||
* breaks so "7:30a<br>9:30a" stays two services rather than one run-on.
|
||||
*/
|
||||
function htmlToLines(html) {
|
||||
return String(html == null ? '' : html)
|
||||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|ul|ol|h[1-6]|td|tr|span|strong|b|em|i|a)\s*>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => decodeEntities(line).replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Title Case a slug: "north-attleboro" → "North Attleboro". */
|
||||
function nameFromSlug(slug) {
|
||||
return String(slug || '')
|
||||
.split(/[-_/]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return String(name == null ? '' : name)
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/** A weekday heading — "Sunday", "Sundays", "SUNDAY" — and nothing else. */
|
||||
function dayFromLine(line) {
|
||||
const cleaned = String(line || '').toLowerCase().replace(/[^a-z]+/g, '');
|
||||
if (!cleaned) return '';
|
||||
for (const day of WEEKDAYS) {
|
||||
if (cleaned === day || cleaned === `${day}s`) {
|
||||
return day.charAt(0).toUpperCase() + day.slice(1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** "9:00a" → { hour24: 9, minute: 0, text: "9:00a" }; 12a/12p handled. */
|
||||
function toService(hourRaw, minuteRaw, meridiemRaw) {
|
||||
const hour12 = Number(hourRaw);
|
||||
const minute = Number(minuteRaw || 0);
|
||||
const pm = String(meridiemRaw || '').toLowerCase() === 'p';
|
||||
if (!Number.isFinite(hour12) || hour12 < 1 || hour12 > 12) return null;
|
||||
if (!Number.isFinite(minute) || minute > 59) return null;
|
||||
const hour24 = pm ? (hour12 === 12 ? 12 : hour12 + 12) : hour12 === 12 ? 0 : hour12;
|
||||
return {
|
||||
hour24,
|
||||
minute,
|
||||
minutes: hour24 * 60 + minute,
|
||||
text: `${hour12}:${pad2(minute)}${pm ? 'p' : 'a'}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Times on one line, with the words in front of the first one as a label
|
||||
* ("Welcome 10:00am" → label "Welcome"). A line holding an hours range
|
||||
* ("9:00a-4:30p") is office hours, not services, and yields nothing.
|
||||
*/
|
||||
function timesFromLine(line) {
|
||||
const text = String(line || '');
|
||||
if (RANGE_RE.test(text)) return [];
|
||||
TIME_RE.lastIndex = 0;
|
||||
const found = [];
|
||||
let first = -1;
|
||||
let match = TIME_RE.exec(text);
|
||||
while (match) {
|
||||
const service = toService(match[1], match[2], match[3]);
|
||||
if (service) {
|
||||
if (first < 0) first = match.index;
|
||||
found.push(service);
|
||||
}
|
||||
match = TIME_RE.exec(text);
|
||||
}
|
||||
if (!found.length) return [];
|
||||
const label = first > 0
|
||||
? text.slice(0, first).replace(/[\s|,;:&/–—-]+$/, '').trim()
|
||||
: '';
|
||||
// A label belongs to a single time ("Start 10:30am"); a list of times on
|
||||
// one line ("9:00a & 11:00a") shares none.
|
||||
return found.map((service) => ({ ...service, label: found.length === 1 ? label : '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the lines of a Times section into services, stopping at the first
|
||||
* stop-heading. Lines before any weekday heading still count — a campus
|
||||
* that lists bare times under "Times" is read as Sunday.
|
||||
*/
|
||||
function walkSection(lines, { defaultDay = 'Sunday' } = {}) {
|
||||
const services = [];
|
||||
let day = '';
|
||||
for (const line of lines) {
|
||||
if (STOP_HEADINGS.test(line)) break;
|
||||
const heading = dayFromLine(line);
|
||||
if (heading) {
|
||||
day = heading;
|
||||
continue;
|
||||
}
|
||||
for (const service of timesFromLine(line)) {
|
||||
services.push({ ...service, day: day || defaultDay });
|
||||
}
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
/** Give every service a stable id — marks are keyed on it, so it must not drift. */
|
||||
function withIds(services) {
|
||||
const seen = new Map();
|
||||
return services.map((service) => {
|
||||
const base = [
|
||||
pad2(service.hour24) + pad2(service.minute),
|
||||
slugify(service.label),
|
||||
].filter(Boolean).join('-');
|
||||
const count = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, count);
|
||||
return { ...service, id: count === 1 ? base : `${base}-${count}` };
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop exact duplicates (same day + time + label) while keeping order. */
|
||||
function dedupe(services) {
|
||||
const seen = new Set();
|
||||
return services.filter((service) => {
|
||||
const key = `${service.day}|${service.minutes}|${service.label.toLowerCase()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a campus page between the "Times" heading and whatever ends
|
||||
* it. Returns '' when the page has no Times heading, which the caller
|
||||
* reports rather than guessing at.
|
||||
*/
|
||||
function timesSection(html) {
|
||||
const text = String(html == null ? '' : html);
|
||||
const start = /<([a-z0-9]+)\b[^>]*>\s*times\s*<\/\1\s*>/i.exec(text)
|
||||
|| />\s*times\s*</i.exec(text);
|
||||
if (!start) return '';
|
||||
const from = start.index + start[0].length;
|
||||
const rest = text.slice(from);
|
||||
const end = /<([a-z0-9]+)\b[^>]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
|
||||
|| />\s*get\s+involved\s*</i.exec(rest);
|
||||
return end ? rest.slice(0, end.index) : rest.slice(0, 20000);
|
||||
}
|
||||
|
||||
/**
|
||||
* One campus page → { slug, name, day, services, error }. `error` is set
|
||||
* (and services empty) when the page no longer looks the way this parser
|
||||
* expects, so the admin health line can say the site changed rather than
|
||||
* quietly showing a campus with no services.
|
||||
*/
|
||||
function parseCampusPage(html, { slug = '', name = '' } = {}) {
|
||||
const campusSlug = slugify(slug);
|
||||
const campusName = name || nameFromSlug(campusSlug);
|
||||
const section = timesSection(html);
|
||||
if (!section) {
|
||||
return { slug: campusSlug, name: campusName, services: [], error: 'No "Times" section on the page' };
|
||||
}
|
||||
const services = withIds(dedupe(walkSection(htmlToLines(section))));
|
||||
if (!services.length) {
|
||||
return { slug: campusSlug, name: campusName, services: [], error: 'No service times under "Times"' };
|
||||
}
|
||||
return { slug: campusSlug, name: campusName, services, error: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Campus page slugs from a sitemap and/or any page's links. The sitemap is
|
||||
* the fuller list — it carries campuses the navigation does not link yet.
|
||||
*/
|
||||
function discoverCampusSlugs(...sources) {
|
||||
const slugs = new Set();
|
||||
for (const source of sources) {
|
||||
const text = String(source == null ? '' : source);
|
||||
const re = /\/locations\/([a-z0-9][a-z0-9-]*)\/?/gi;
|
||||
let match = re.exec(text);
|
||||
while (match) {
|
||||
const slug = slugify(match[1]);
|
||||
if (slug && slug !== 'locations') slugs.add(slug);
|
||||
match = re.exec(text);
|
||||
}
|
||||
}
|
||||
return [...slugs].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Manual campuses" text: one campus per line,
|
||||
*
|
||||
* Name | Day | 9:00a, 11:00a
|
||||
*
|
||||
* Day is optional and defaults to Sunday: "Plymouth | 9:00a & 11:00a".
|
||||
* A line the parser cannot read is returned in `errors` so the admin page
|
||||
* can say which line, rather than the campus silently not appearing.
|
||||
*/
|
||||
function parseManualCampuses(text) {
|
||||
const campuses = [];
|
||||
const errors = [];
|
||||
const lines = String(text == null ? '' : text).split(/\r?\n/);
|
||||
lines.forEach((raw, index) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const parts = line.split('|').map((part) => part.trim());
|
||||
const name = parts.shift() || '';
|
||||
if (!name) return;
|
||||
if (!parts.length) {
|
||||
errors.push(`Line ${index + 1}: "${line}" has no times`);
|
||||
return;
|
||||
}
|
||||
let day = '';
|
||||
if (parts.length > 1) {
|
||||
day = dayFromLine(parts[0]) || '';
|
||||
if (day) parts.shift();
|
||||
}
|
||||
const services = withIds(dedupe(
|
||||
walkSection(parts.join('\n').split('\n'), { defaultDay: day || 'Sunday' })
|
||||
));
|
||||
if (!services.length) {
|
||||
errors.push(`Line ${index + 1}: no times found in "${line}"`);
|
||||
return;
|
||||
}
|
||||
campuses.push({ slug: slugify(name), name, services, manual: true, error: '' });
|
||||
});
|
||||
return { campuses, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Website campuses and manual ones into the one list the tiles draw.
|
||||
*
|
||||
* The website wins: a manual campus is a stopgap for a site that has not
|
||||
* caught up, so as soon as the site publishes that campus (same slug) its
|
||||
* times take over and the manual entry is marked `supersededBy` — the admin
|
||||
* page says so, and the operator can delete the line.
|
||||
*/
|
||||
function mergeCampuses(siteCampuses, manualCampuses) {
|
||||
const merged = [];
|
||||
const bySlug = new Map();
|
||||
for (const campus of siteCampuses || []) {
|
||||
if (!campus || !campus.slug) continue;
|
||||
const entry = { ...campus, manual: false, superseded: false };
|
||||
bySlug.set(campus.slug, entry);
|
||||
merged.push(entry);
|
||||
}
|
||||
for (const campus of manualCampuses || []) {
|
||||
if (!campus || !campus.slug) continue;
|
||||
const fromSite = bySlug.get(campus.slug);
|
||||
if (fromSite) {
|
||||
// The site has it now — its times are the ones that matter.
|
||||
if (fromSite.services.length) {
|
||||
fromSite.supersededManual = true;
|
||||
continue;
|
||||
}
|
||||
// The page exists but gave us nothing usable; the manual line still helps.
|
||||
fromSite.services = campus.services;
|
||||
fromSite.manual = true;
|
||||
fromSite.error = '';
|
||||
continue;
|
||||
}
|
||||
const entry = { ...campus, superseded: false };
|
||||
bySlug.set(campus.slug, entry);
|
||||
merged.push(entry);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Resi sources" text: one campus per line,
|
||||
*
|
||||
* woonsocket = north-attleboro
|
||||
*
|
||||
* Names work as well as slugs — both sides are slugified, so an operator can
|
||||
* write what they say out loud. "->", ":" and "→" read as "=".
|
||||
*/
|
||||
function parseFeeds(text) {
|
||||
const feeds = new Map();
|
||||
const errors = [];
|
||||
String(text == null ? '' : text).split(/\r?\n/).forEach((raw, index) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const match = /^(.+?)\s*(?:=|->|:|→)\s*(.+)$/.exec(line);
|
||||
if (!match) {
|
||||
errors.push(`Line ${index + 1}: "${line}" is not "campus = broadcast campus"`);
|
||||
return;
|
||||
}
|
||||
const from = slugify(match[1]);
|
||||
const to = slugify(match[2]);
|
||||
if (!from || !to) {
|
||||
errors.push(`Line ${index + 1}: "${line}" is missing a campus`);
|
||||
return;
|
||||
}
|
||||
feeds.set(from, to);
|
||||
});
|
||||
return { feeds, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every campus its production role: which ones originate the broadcast,
|
||||
* which one each of the others takes its resi from, and — the question the
|
||||
* board exists to answer — who is riding on each origin.
|
||||
*
|
||||
* A resi campus pointed at a campus that is not on the board keeps the
|
||||
* pointer and is flagged (`sourceMissing`) rather than quietly reading as
|
||||
* unassigned: a typo in the admin text has to be visible, not invisible.
|
||||
*/
|
||||
function assignRoles(campuses, { broadcast = [], feeds = new Map() } = {}) {
|
||||
const origins = new Set([...broadcast].map((slug) => slugify(slug)).filter(Boolean));
|
||||
const shaped = (campuses || []).map((campus) => {
|
||||
const role = origins.has(campus.slug) ? 'broadcast' : 'resi';
|
||||
const source = role === 'broadcast' ? '' : feeds.get(campus.slug) || '';
|
||||
return { ...campus, role, source, sourceName: '', sourceMissing: false, dependents: [] };
|
||||
});
|
||||
const bySlug = new Map(shaped.map((campus) => [campus.slug, campus]));
|
||||
for (const campus of shaped) {
|
||||
if (!campus.source) continue;
|
||||
const origin = bySlug.get(campus.source);
|
||||
campus.sourceName = origin ? origin.name : campus.source;
|
||||
campus.sourceMissing = !origin;
|
||||
if (origin) origin.dependents.push({ slug: campus.slug, name: campus.name });
|
||||
}
|
||||
return shaped;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
decodeEntities,
|
||||
htmlToLines,
|
||||
nameFromSlug,
|
||||
slugify,
|
||||
dayFromLine,
|
||||
timesFromLine,
|
||||
walkSection,
|
||||
timesSection,
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the Service Times parser.
|
||||
*
|
||||
* node modules/service-times/parse.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. The
|
||||
* fixtures are trimmed from the real campus pages, so each one stands for a
|
||||
* shape the website actually publishes — including Apollo Beach's "Office
|
||||
* Hours" block, which sits inside the Times section and must never be read
|
||||
* as a service.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
htmlToLines,
|
||||
slugify,
|
||||
dayFromLine,
|
||||
timesFromLine,
|
||||
timesSection,
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
} = require('./parse');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${name}\n ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** The page-builder wrapper every campus's Times block sits in. */
|
||||
const page = (inner) => `
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Address</h5><p>1 Example Road</p></div>
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Times</h5>
|
||||
${inner}
|
||||
</div>
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Get Involved</h5><p>Give now</p></div>
|
||||
<div class="footer"><p>Mon – Thu 9a – 4:30p</p></div>
|
||||
`;
|
||||
|
||||
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>');
|
||||
const NORTH_ATTLEBORO = page('<p><strong>Sunday</strong></p><p>7:30a<br />9:30a<br />11:30a</p>');
|
||||
const ORLANDO = page('<p><b>Sundays</b></p><p>Welcome 10:00am</p><p>Start 10:30am</p>');
|
||||
const SOUTHCOAST = page('<p><strong>Sunday</strong></p><p>10:30a</p>');
|
||||
const APOLLO_BEACH = page(
|
||||
'<p><strong style="color:#333">Sunday</strong></p>'
|
||||
+ '<p><span style="color:#333">9:00a & 11:00a</span></p>'
|
||||
+ '<h5 class="q_title">Office Hours</h5><p>Mon-Wed 9:00a-4:30p</p>'
|
||||
);
|
||||
|
||||
const times = (html, slug) => parseCampusPage(html, { slug }).services.map((s) => s.text);
|
||||
|
||||
console.log('campus pages');
|
||||
|
||||
test('Woonsocket: two times joined by "&" on one line', () => {
|
||||
assert.deepStrictEqual(times(WOONSOCKET, 'woonsocket'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
test('North Attleboro: three times separated by <br>', () => {
|
||||
assert.deepStrictEqual(times(NORTH_ATTLEBORO, 'north-attleboro'), ['7:30a', '9:30a', '11:30a']);
|
||||
});
|
||||
|
||||
test('Southcoast: a single service', () => {
|
||||
assert.deepStrictEqual(times(SOUTHCOAST, 'southcoast'), ['10:30a']);
|
||||
});
|
||||
|
||||
test('Orlando: "am" spelling, one labelled time per line', () => {
|
||||
const services = parseCampusPage(ORLANDO, { slug: 'orlando' }).services;
|
||||
assert.deepStrictEqual(services.map((s) => s.text), ['10:00a', '10:30a']);
|
||||
assert.deepStrictEqual(services.map((s) => s.label), ['Welcome', 'Start']);
|
||||
});
|
||||
|
||||
test('Apollo Beach: the Office Hours block inside Times is not a service', () => {
|
||||
assert.deepStrictEqual(times(APOLLO_BEACH, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
/* Apollo Beach's real page lists an hours *range* under Office Hours, which the
|
||||
range guard rejects on its own — so the test above passes even if the
|
||||
stop-heading breaks. This fixture puts plain times there instead, where only
|
||||
the stop-heading can keep them out: without it, the 2:00p becomes a service. */
|
||||
const OFFICE_HOURS_PLAIN = page(
|
||||
'<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>'
|
||||
+ '<h5 class="q_title">Office Hours</h5><p>Mon 9:00a</p><p>Thu 2:00p</p>'
|
||||
);
|
||||
|
||||
test('an Office Hours block ends the service list, range or not', () => {
|
||||
assert.deepStrictEqual(times(OFFICE_HOURS_PLAIN, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
|
||||
test('the footer\'s main-office hours never reach the services', () => {
|
||||
for (const [html, slug] of [[WOONSOCKET, 'woonsocket'], [SOUTHCOAST, 'southcoast']]) {
|
||||
assert.ok(!times(html, slug).includes('4:30p'), `${slug} picked up the footer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('names and days come out as the site writes them', () => {
|
||||
const campus = parseCampusPage(NORTH_ATTLEBORO, { slug: 'north-attleboro' });
|
||||
assert.strictEqual(campus.name, 'North Attleboro');
|
||||
assert.deepStrictEqual([...new Set(campus.services.map((s) => s.day))], ['Sunday']);
|
||||
});
|
||||
|
||||
test('ids are stable and unique — marks are keyed on them', () => {
|
||||
const first = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||
const again = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||
assert.deepStrictEqual(first, ['1000-welcome', '1030-start']);
|
||||
assert.deepStrictEqual(first, again);
|
||||
assert.strictEqual(new Set(first).size, first.length);
|
||||
});
|
||||
|
||||
test('a page that no longer has a Times section reports it', () => {
|
||||
const campus = parseCampusPage('<h1>Redirected elsewhere</h1>', { slug: 'norwood' });
|
||||
assert.deepStrictEqual(campus.services, []);
|
||||
assert.match(campus.error, /Times/);
|
||||
});
|
||||
|
||||
console.log('pieces');
|
||||
|
||||
test('24-hour values and sort order', () => {
|
||||
const [nine] = timesFromLine('9:00a');
|
||||
const [noon] = timesFromLine('12:00p');
|
||||
const [midnight] = timesFromLine('12:00a');
|
||||
const [evening] = timesFromLine('6:30p');
|
||||
assert.strictEqual(nine.hour24, 9);
|
||||
assert.strictEqual(noon.hour24, 12);
|
||||
assert.strictEqual(midnight.hour24, 0);
|
||||
assert.strictEqual(evening.minutes, 18 * 60 + 30);
|
||||
});
|
||||
|
||||
test('an hours range is not a service list', () => {
|
||||
assert.deepStrictEqual(timesFromLine('Mon-Wed 9:00a-4:30p'), []);
|
||||
assert.deepStrictEqual(timesFromLine('9a – 4:30p'), []);
|
||||
});
|
||||
|
||||
test('prose that merely contains a number is not a time', () => {
|
||||
assert.deepStrictEqual(timesFromLine('1 pastor, 2 people'), []);
|
||||
assert.deepStrictEqual(timesFromLine('Apollo Beach campus'), []);
|
||||
});
|
||||
|
||||
test('weekday headings, singular or plural, in any case', () => {
|
||||
assert.strictEqual(dayFromLine('Sunday'), 'Sunday');
|
||||
assert.strictEqual(dayFromLine('SUNDAYS'), 'Sunday');
|
||||
assert.strictEqual(dayFromLine(' Saturday '), 'Saturday');
|
||||
assert.strictEqual(dayFromLine('Sunday 9:00a'), '');
|
||||
assert.strictEqual(dayFromLine('Get Involved'), '');
|
||||
});
|
||||
|
||||
test('<br> and block ends become line breaks', () => {
|
||||
assert.deepStrictEqual(htmlToLines('<p>7:30a<br />9:30a</p>'), ['7:30a', '9:30a']);
|
||||
assert.deepStrictEqual(htmlToLines('<p>9:00a & 11:00a</p>'), ['9:00a & 11:00a']);
|
||||
});
|
||||
|
||||
test('the Times slice stops at Get Involved', () => {
|
||||
const section = timesSection(WOONSOCKET);
|
||||
assert.ok(section.includes('9:00a'));
|
||||
assert.ok(!section.includes('Give now'));
|
||||
});
|
||||
|
||||
test('campus slugs are discovered from a sitemap', () => {
|
||||
const xml = `<url><loc>https://waterschurch.org/locations/</loc></url>
|
||||
<url><loc>https://waterschurch.org/locations/north-attleboro/</loc></url>
|
||||
<url><loc>https://waterschurch.org/locations/norwood/</loc></url>`;
|
||||
assert.deepStrictEqual(discoverCampusSlugs(xml), ['north-attleboro', 'norwood']);
|
||||
});
|
||||
|
||||
test('slugs survive punctuation and case', () => {
|
||||
assert.strictEqual(slugify('North Attleboro'), 'north-attleboro');
|
||||
assert.strictEqual(slugify(' Apollo Beach! '), 'apollo-beach');
|
||||
});
|
||||
|
||||
console.log('manual campuses');
|
||||
|
||||
test('name, day and times — day optional', () => {
|
||||
const { campuses, errors } = parseManualCampuses(
|
||||
'Plymouth | Sunday | 9:00a, 11:00a\nQuincy | 10:30a\n'
|
||||
);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(campuses.map((c) => c.name), ['Plymouth', 'Quincy']);
|
||||
assert.deepStrictEqual(campuses[0].services.map((s) => s.text), ['9:00a', '11:00a']);
|
||||
assert.deepStrictEqual(campuses[1].services.map((s) => s.day), ['Sunday']);
|
||||
assert.ok(campuses[0].manual);
|
||||
});
|
||||
|
||||
test('blank lines and # comments are skipped; a line with no time is reported', () => {
|
||||
const { campuses, errors } = parseManualCampuses('\n# not yet on the site\nPlymouth | soon\n');
|
||||
assert.deepStrictEqual(campuses, []);
|
||||
assert.strictEqual(errors.length, 1);
|
||||
assert.match(errors[0], /Line 3/);
|
||||
});
|
||||
|
||||
console.log('merging');
|
||||
|
||||
test('the website wins once it publishes a campus the operator added by hand', () => {
|
||||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [{ id: '0900', text: '9:00a' }], error: '' }];
|
||||
const manual = parseManualCampuses('Plymouth | 10:30a').campuses;
|
||||
const merged = mergeCampuses(site, manual);
|
||||
assert.strictEqual(merged.length, 1);
|
||||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['9:00a']);
|
||||
assert.strictEqual(merged[0].manual, false);
|
||||
assert.ok(merged[0].supersededManual, 'the admin page should be able to say the line can go');
|
||||
});
|
||||
|
||||
test('a manual campus the site does not have yet is kept', () => {
|
||||
const merged = mergeCampuses([], parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||
assert.deepStrictEqual(merged.map((c) => c.name), ['Plymouth']);
|
||||
assert.ok(merged[0].manual);
|
||||
});
|
||||
|
||||
test('a site page that parses to nothing falls back to the manual line', () => {
|
||||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [], error: 'No "Times" section on the page' }];
|
||||
const merged = mergeCampuses(site, parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['10:30a']);
|
||||
assert.strictEqual(merged[0].error, '');
|
||||
assert.ok(merged[0].manual);
|
||||
});
|
||||
|
||||
console.log('resi sources');
|
||||
|
||||
test('campus = broadcast campus, by name or slug, with -> and : accepted', () => {
|
||||
const { feeds, errors } = parseFeeds(
|
||||
'Woonsocket = North Attleboro\nsouthcoast -> north-attleboro\n# a comment\n\nOrlando: Apollo Beach'
|
||||
);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.strictEqual(feeds.get('woonsocket'), 'north-attleboro');
|
||||
assert.strictEqual(feeds.get('southcoast'), 'north-attleboro');
|
||||
assert.strictEqual(feeds.get('orlando'), 'apollo-beach');
|
||||
});
|
||||
|
||||
test('a line that names no source is reported, not dropped in silence', () => {
|
||||
const { feeds, errors } = parseFeeds('woonsocket\nsouthcoast = north-attleboro');
|
||||
assert.strictEqual(feeds.size, 1);
|
||||
assert.strictEqual(errors.length, 1);
|
||||
assert.match(errors[0], /Line 1/);
|
||||
});
|
||||
|
||||
console.log('roles and dependents');
|
||||
|
||||
/** The four campuses production runs, set up the way the booth runs them. */
|
||||
const BOARD = [
|
||||
{ slug: 'north-attleboro', name: 'North Attleboro', services: [{ id: '0730' }, { id: '0930' }, { id: '1130' }] },
|
||||
{ slug: 'woonsocket', name: 'Woonsocket', services: [{ id: '0900' }, { id: '1100' }] },
|
||||
{ slug: 'southcoast', name: 'Southcoast', services: [{ id: '1030' }] },
|
||||
{ slug: 'apollo-beach', name: 'Apollo Beach', services: [{ id: '0900' }, { id: '1100' }] },
|
||||
];
|
||||
const SETUP = {
|
||||
broadcast: ['north-attleboro', 'apollo-beach'],
|
||||
feeds: parseFeeds('woonsocket = north-attleboro\nsouthcoast = north-attleboro').feeds,
|
||||
};
|
||||
const byName = (campuses, name) => campuses.find((c) => c.name === name);
|
||||
|
||||
test('the broadcast campuses originate and take resi from nobody', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
for (const name of ['North Attleboro', 'Apollo Beach']) {
|
||||
assert.strictEqual(byName(roles, name).role, 'broadcast', name);
|
||||
assert.strictEqual(byName(roles, name).source, '', `${name} should have no source`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every other campus takes resi from the campus it is mapped to', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
for (const name of ['Woonsocket', 'Southcoast']) {
|
||||
const campus = byName(roles, name);
|
||||
assert.strictEqual(campus.role, 'resi', name);
|
||||
assert.strictEqual(campus.source, 'north-attleboro', name);
|
||||
assert.strictEqual(campus.sourceName, 'North Attleboro', name);
|
||||
assert.strictEqual(campus.sourceMissing, false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('an origin lists who is riding on it — the answer the board exists for', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
assert.deepStrictEqual(
|
||||
byName(roles, 'North Attleboro').dependents.map((d) => d.name),
|
||||
['Woonsocket', 'Southcoast']
|
||||
);
|
||||
assert.deepStrictEqual(byName(roles, 'Apollo Beach').dependents, []);
|
||||
});
|
||||
|
||||
test('a source that is not on the board is flagged, not read as unassigned', () => {
|
||||
const roles = assignRoles(BOARD, {
|
||||
broadcast: ['north-attleboro'],
|
||||
feeds: parseFeeds('woonsocket = attleboro-north').feeds, // a typo
|
||||
});
|
||||
const woonsocket = byName(roles, 'Woonsocket');
|
||||
assert.strictEqual(woonsocket.source, 'attleboro-north');
|
||||
assert.strictEqual(woonsocket.sourceMissing, true);
|
||||
assert.deepStrictEqual(byName(roles, 'North Attleboro').dependents, []);
|
||||
});
|
||||
|
||||
test('a campus with no mapping reads as having no source at all', () => {
|
||||
const roles = assignRoles(BOARD, { broadcast: ['north-attleboro'], feeds: new Map() });
|
||||
assert.strictEqual(byName(roles, 'Southcoast').source, '');
|
||||
assert.strictEqual(byName(roles, 'Southcoast').sourceMissing, false);
|
||||
});
|
||||
|
||||
test('assignRoles leaves the campuses it was handed alone', () => {
|
||||
const input = [{ slug: 'woonsocket', name: 'Woonsocket', services: [] }];
|
||||
assignRoles(input, SETUP);
|
||||
assert.strictEqual(input[0].role, undefined, 'the caller’s objects must not be rewritten');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,570 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service Times — server part.
|
||||
*
|
||||
* Owns the one connection to the church website, keeps every campus's Sunday
|
||||
* service times in memory, and fans them — together with the production marks
|
||||
* an operator puts on a service — out to every tile over SSE. Browsers never
|
||||
* talk to the website.
|
||||
*
|
||||
* GET /state → { state } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* POST /mark → mark one service (replay / do not use / issue)
|
||||
* POST /clear → drop every mark (a fresh week)
|
||||
* POST /refresh → re-read the website now
|
||||
* GET /campuses → { options } — the campuses in play, for the admin
|
||||
* broadcast picker and a tile's campus list
|
||||
* GET /discovered → { options } — every campus page the website publishes,
|
||||
* for the admin "Campuses" picker
|
||||
*
|
||||
* Why the marks live here and not in a tile's settings: a mark is the booth's
|
||||
* shared answer to "is this service usable?", and every campus watching the
|
||||
* board has to see the same one. Per-tile settings are per browser, so they
|
||||
* would let two people disagree about whether the 9:30 is going out. Marks
|
||||
* are written to the ProdDash data directory so a restart mid-morning does
|
||||
* not lose them, and they expire on their own (admin "Marks") so last week's
|
||||
* never greet a new Sunday.
|
||||
*
|
||||
* The website is the source of truth for times. Manual campuses (admin) are a
|
||||
* stopgap for a campus the site has not published yet; the moment the site
|
||||
* carries it, the site's times take over and the admin page says the manual
|
||||
* line can go.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
slugify,
|
||||
} = require('./parse');
|
||||
|
||||
/** Abort a hung page read well before the refresh cadence. */
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** Fastest allowed re-read: service times change a few times a year. */
|
||||
const MIN_REFRESH_MINUTES = 5;
|
||||
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
|
||||
const RETRY_MS = 60000;
|
||||
/** Guard against a redirect loop or a runaway campus list. */
|
||||
const MAX_CAMPUSES = 40;
|
||||
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||
|
||||
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
|
||||
const NOTE_LIMIT = 280;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function sendJson(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > BODY_LIMIT_BYTES) {
|
||||
reject(new Error('Request body too large.'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data.trim()) return resolve({});
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
resolve(body && typeof body === 'object' ? body : {});
|
||||
} catch {
|
||||
reject(new Error('Malformed JSON.'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||
function guard(fn) {
|
||||
return (req, res) => {
|
||||
Promise.resolve(fn(req, res)).catch((err) => {
|
||||
try {
|
||||
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
|
||||
} catch { /* the client is already gone */ }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where ProdDash keeps per-machine state, resolved the way the shell does.
|
||||
* Never the checkout: the repo folder is replaced on update.
|
||||
*/
|
||||
function resolveDataDir() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||
if (fromEnv) return path.resolve(root, fromEnv);
|
||||
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');
|
||||
}
|
||||
|
||||
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
|
||||
|
||||
function markKey(campus, service) {
|
||||
return `${campus}::${service}`;
|
||||
}
|
||||
|
||||
/* ── admin config shaping ───────────────────────────────────────────── */
|
||||
|
||||
function siteBase(config) {
|
||||
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!raw) return '';
|
||||
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
}
|
||||
|
||||
function listSetting(value) {
|
||||
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
|
||||
if (typeof value === 'string') {
|
||||
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* ── the website ────────────────────────────────────────────────────── */
|
||||
|
||||
async function getText(url) {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
|
||||
async function discover(base, log) {
|
||||
const sources = [];
|
||||
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
|
||||
try {
|
||||
sources.push(await getText(base + target));
|
||||
if (target.includes('sitemap')) break; // a sitemap is the fuller list
|
||||
} catch (err) {
|
||||
log(`discovery: ${target} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (!sources.length) return [];
|
||||
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
|
||||
}
|
||||
|
||||
async function readCampus(base, slug) {
|
||||
try {
|
||||
const html = await getText(`${base}/locations/${slug}/`);
|
||||
return parseCampusPage(html, { slug });
|
||||
} catch (err) {
|
||||
return {
|
||||
slug,
|
||||
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
services: [],
|
||||
error: `Page unreadable: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── module ─────────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const base = siteBase(config);
|
||||
|
||||
const chosen = listSetting(config.campuses);
|
||||
const broadcast = new Set(listSetting(config.broadcast));
|
||||
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
|
||||
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
|
||||
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
|
||||
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
|
||||
|
||||
let marks = new Map();
|
||||
let marksWritable = true;
|
||||
let discovered = [];
|
||||
let siteCampuses = [];
|
||||
let reachable = false;
|
||||
let lastError = base ? '' : 'No website address configured';
|
||||
let lastFetched = 0;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
/* ── marks: load, save, expire ──────────────────────────────────── */
|
||||
|
||||
function loadMarks() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
|
||||
for (const [key, value] of Object.entries(saved?.marks || {})) {
|
||||
if (value && typeof value === 'object') marks.set(key, value);
|
||||
}
|
||||
log(`${marks.size} mark(s) restored`);
|
||||
} catch {
|
||||
/* no marks yet — the usual case */
|
||||
}
|
||||
}
|
||||
|
||||
function saveMarks() {
|
||||
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
|
||||
const tmp = `${MARKS_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
|
||||
fs.renameSync(tmp, MARKS_FILE);
|
||||
marksWritable = true;
|
||||
} catch (err) {
|
||||
// Never fall back to writing inside the checkout: the marks stay in
|
||||
// memory and health() says they will not survive a restart.
|
||||
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
|
||||
marksWritable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop marks older than the retention window. Returns true if any went. */
|
||||
function expireMarks() {
|
||||
if (!retentionHours) return false;
|
||||
const cutoff = Date.now() - retentionHours * 3600000;
|
||||
let dropped = false;
|
||||
for (const [key, mark] of marks) {
|
||||
if (!(Number(mark?.at) > cutoff)) {
|
||||
marks.delete(key);
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
if (dropped) saveMarks();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/* ── the view every tile draws ──────────────────────────────────── */
|
||||
|
||||
function campusList() {
|
||||
const wanted = chosen.length
|
||||
? siteCampuses.filter((c) => chosen.includes(c.slug))
|
||||
: siteCampuses;
|
||||
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
|
||||
|
||||
// Marks ride along on the service they belong to.
|
||||
for (const campus of merged) {
|
||||
campus.services = campus.services.map((service) => {
|
||||
const mark = marks.get(markKey(campus.slug, service.id)) || null;
|
||||
return { ...service, mark };
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
configured: Boolean(base),
|
||||
reachable,
|
||||
lastError,
|
||||
lastFetched,
|
||||
siteUrl: base,
|
||||
refreshMinutes: refreshMs / 60000,
|
||||
retentionHours,
|
||||
marksWritable,
|
||||
campuses: campusList(),
|
||||
discovered,
|
||||
manualErrors,
|
||||
feedErrors,
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastState({ force = false } = {}) {
|
||||
if (!streams.size) return;
|
||||
const view = snapshot();
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// `now` changes every call; compare without it so idle tiles stay idle.
|
||||
const compare = frame.replace(/,"now":\d+/, '');
|
||||
if (!force && compare === lastFrame) return;
|
||||
lastFrame = compare;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the refresh loop ───────────────────────────────────────────── */
|
||||
|
||||
async function refresh() {
|
||||
if (stopped) return;
|
||||
if (!base) {
|
||||
reachable = false;
|
||||
lastError = 'No website address configured';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = await discover(base, log);
|
||||
if (found.length) discovered = found;
|
||||
const slugs = chosen.length ? chosen : discovered;
|
||||
if (!slugs.length) throw new Error('No campus pages found on the site');
|
||||
|
||||
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
|
||||
const usable = read.filter((campus) => campus.services.length);
|
||||
|
||||
// A total failure keeps the last good times on screen — a site outage
|
||||
// must not blank the board in the middle of a service.
|
||||
if (!usable.length && siteCampuses.length) {
|
||||
throw new Error(read[0]?.error || 'No service times found on the site');
|
||||
}
|
||||
siteCampuses = read;
|
||||
reachable = true;
|
||||
lastFetched = Date.now();
|
||||
const broken = read.filter((campus) => campus.error);
|
||||
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
|
||||
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
|
||||
} catch (err) {
|
||||
reachable = false;
|
||||
lastError = err?.message || String(err);
|
||||
log(`refresh failed: ${lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(delay) {
|
||||
clearTimeout(timer);
|
||||
if (stopped) return;
|
||||
timer = setTimeout(async () => {
|
||||
await refresh();
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
schedule(reachable ? refreshMs : RETRY_MS);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
loadMarks();
|
||||
expireMarks();
|
||||
if (base) {
|
||||
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
|
||||
} else {
|
||||
log('no website address configured — set one in /admin');
|
||||
}
|
||||
// First read immediately, then on the configured cadence.
|
||||
schedule(0);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = {
|
||||
streams,
|
||||
snapshot,
|
||||
broadcastState,
|
||||
refreshNow: async () => {
|
||||
await refresh();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
setMark(campusSlug, serviceId, patch) {
|
||||
const key = markKey(slugify(campusSlug), String(serviceId || ''));
|
||||
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
|
||||
if (status === 'ok' && !patch.note && !patch.source) {
|
||||
marks.delete(key);
|
||||
} else {
|
||||
marks.set(key, {
|
||||
status,
|
||||
note: String(patch.note || '').slice(0, NOTE_LIMIT),
|
||||
source: String(patch.source || '').slice(0, 120),
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
clearMarks() {
|
||||
const had = marks.size;
|
||||
marks.clear();
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
return had;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(heartbeat);
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!base) return { status: 'error', message: 'No website address configured' };
|
||||
const notes = [];
|
||||
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
|
||||
if (manualErrors.length) notes.push(manualErrors[0]);
|
||||
if (feedErrors.length) notes.push(feedErrors[0]);
|
||||
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
|
||||
if (superseded.length) {
|
||||
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
|
||||
}
|
||||
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
|
||||
if (!reachable) {
|
||||
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
|
||||
}
|
||||
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
|
||||
const warn = lastError || notes.length;
|
||||
return {
|
||||
status: warn ? 'connecting' : 'ok',
|
||||
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The board, plus one entry per campus. A campus booth wants its own times
|
||||
* and the service it is taking resi from — not the whole network — so those
|
||||
* entries fix the campus and drop the campus picker from their gear.
|
||||
*/
|
||||
tiles() {
|
||||
const board = {
|
||||
id: 'board',
|
||||
name: 'Service Times',
|
||||
description: 'Every campus, its services, and what is marked for replay or unusable',
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
settings: {},
|
||||
};
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
return [
|
||||
board,
|
||||
...campuses.map((campus) => ({
|
||||
id: campus.slug,
|
||||
name: campus.name,
|
||||
description: `${campus.name} service times`,
|
||||
defaultSize: { w: 3, h: 4 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { campuses: [campus.slug] },
|
||||
instanceSchema: {
|
||||
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
|
||||
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
|
||||
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
|
||||
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
|
||||
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
|
||||
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||
})),
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
sendJson(res, 200, { state: current ? current.snapshot() : null });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
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');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||
// not queue frames forever; keepalive probes make the OS notice.
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /mark': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
const body = await readJsonBody(req);
|
||||
const campus = String(body.campus || '').trim();
|
||||
const service = String(body.service || '').trim();
|
||||
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
|
||||
current.setMark(campus, service, {
|
||||
status: body.status,
|
||||
note: body.note,
|
||||
source: body.source,
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
}),
|
||||
|
||||
'POST /clear': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
|
||||
}),
|
||||
|
||||
'POST /refresh': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
await current.refreshNow();
|
||||
sendJson(res, 200, { ok: true, state: current.snapshot() });
|
||||
}),
|
||||
|
||||
'GET /campuses': (req, res) => {
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
sendJson(res, 200, {
|
||||
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
|
||||
});
|
||||
},
|
||||
|
||||
'GET /discovered': (req, res) => {
|
||||
const state = current ? current.snapshot() : null;
|
||||
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
|
||||
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
|
||||
sendJson(res, 200, {
|
||||
options: slugs.map((slug) => ({
|
||||
value: slug,
|
||||
label: names.get(slug)
|
||||
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
})),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
/* Service Times — scoped under its tiles via [data-module="service-times"],
|
||||
styled with the shell's CSS variables only so themes reach into it. */
|
||||
|
||||
[data-module="service-times"] .st-board {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* ── a campus ─────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-campus {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--text) 8%);
|
||||
padding: 7px 8px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-is-broadcast {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-campus-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-campus-name {
|
||||
margin: 0;
|
||||
font-size: 1.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role {
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-broadcast {
|
||||
color: var(--accent-text, var(--text));
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-manual {
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-unset {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-feeds {
|
||||
margin: 0 0 5px;
|
||||
font-size: 0.76em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── a service ────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
border: 1px solid transparent;
|
||||
border-left: 3px solid color-mix(in srgb, var(--text) 22%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 4px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row:hover,
|
||||
[data-module="service-times"] .st-row:focus-visible {
|
||||
border-color: var(--border);
|
||||
border-left-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row.st-open {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-time {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-day {
|
||||
font-size: 0.74em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-issue,
|
||||
[data-module="service-times"] .st-chip-warn {
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-replay {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-skip {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-source,
|
||||
[data-module="service-times"] .st-chip-quiet {
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Marked services read at a glance from across the booth. */
|
||||
[data-module="service-times"] .st-row.st-issue { border-left-color: var(--warn); }
|
||||
[data-module="service-times"] .st-row.st-replay { border-left-color: var(--accent); }
|
||||
[data-module="service-times"] .st-row.st-skip {
|
||||
border-left-color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
[data-module="service-times"] .st-row.st-skip .st-time {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
[data-module="service-times"] .st-row.st-affected {
|
||||
border-left-color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-note {
|
||||
flex: 1 0 100%;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── messages ─────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-empty,
|
||||
[data-module="service-times"] .st-updated {
|
||||
margin: 4px 2px 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-warn {
|
||||
margin: 6px 2px 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-editor {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick {
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick.active,
|
||||
[data-module="service-times"] .st-save {
|
||||
color: var(--accent-text, var(--text));
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-label {
|
||||
font-size: 0.72em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-input {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Cal Henderson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"version": "1.2.1",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"configGroups": {
|
||||
"Slack account": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||
},
|
||||
"Channels": {
|
||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||
},
|
||||
"Sending": {
|
||||
"toggle": "sendEnabled",
|
||||
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"token": {
|
||||
"type": "password",
|
||||
"label": "Token",
|
||||
"default": "",
|
||||
"group": "Slack account",
|
||||
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
|
||||
},
|
||||
"sendAs": {
|
||||
"type": "string",
|
||||
"label": "Send as",
|
||||
"default": "",
|
||||
"group": "Slack account",
|
||||
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
|
||||
},
|
||||
"channels": {
|
||||
"type": "multiselect",
|
||||
"label": "Channels",
|
||||
"default": [],
|
||||
"group": "Channels",
|
||||
"optionsRoute": "/channels",
|
||||
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
"label": "Refresh interval (s)",
|
||||
"default": 2,
|
||||
"group": "Transcript",
|
||||
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
|
||||
},
|
||||
"historyCount": {
|
||||
"type": "number",
|
||||
"label": "Messages kept",
|
||||
"default": 40,
|
||||
"group": "Transcript"
|
||||
},
|
||||
"includeReplies": {
|
||||
"type": "switch",
|
||||
"label": "Thread replies",
|
||||
"default": false,
|
||||
"group": "Transcript"
|
||||
},
|
||||
"sendEnabled": {
|
||||
"type": "switch",
|
||||
"label": "Sending",
|
||||
"default": false,
|
||||
"group": "Sending"
|
||||
},
|
||||
"pin": {
|
||||
"type": "password",
|
||||
"label": "PIN",
|
||||
"default": "",
|
||||
"group": "Sending",
|
||||
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
||||
},
|
||||
"pinIdleMinutes": {
|
||||
"type": "number",
|
||||
"label": "Auto-lock (min)",
|
||||
"default": 10,
|
||||
"group": "Sending"
|
||||
},
|
||||
"quickReplies": {
|
||||
"type": "text",
|
||||
"label": "Quick replies",
|
||||
"default": "",
|
||||
"group": "Sending",
|
||||
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
|
||||
},
|
||||
"quickEmojis": {
|
||||
"type": "string",
|
||||
"label": "Quick emojis",
|
||||
"default": "👍 🙏 ✅ 👀 🎉",
|
||||
"group": "Sending",
|
||||
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showAvatars": {
|
||||
"type": "switch",
|
||||
"label": "Avatars",
|
||||
"default": true
|
||||
},
|
||||
"showTimes": {
|
||||
"type": "switch",
|
||||
"label": "Timestamps",
|
||||
"default": true
|
||||
},
|
||||
"showNames": {
|
||||
"type": "switch",
|
||||
"label": "Names",
|
||||
"default": true
|
||||
},
|
||||
"showSystem": {
|
||||
"type": "switch",
|
||||
"label": "Channel notices",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,677 @@
|
||||
/* Slack module — scoped to its tiles, shell variables only.
|
||||
--sl-size (per tile, from the header's A− / A+) drives every font size.
|
||||
Transcript rows follow the ProdCom transcript's layout: a square picture,
|
||||
then a name line with the time, the text, the reactions. */
|
||||
|
||||
[data-module="slack"] .sl-wrap {
|
||||
--sl-size: 14px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: size;
|
||||
font-size: var(--sl-size);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="slack"] .sl-wrap:focus { outline: none; }
|
||||
[data-module="slack"] [hidden] { display: none !important; }
|
||||
|
||||
/* ── degraded banner (transcript) ── */
|
||||
|
||||
[data-module="slack"] .sl-banner {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8em;
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
|
||||
}
|
||||
[data-module="slack"] .sl-banner.is-error {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border));
|
||||
}
|
||||
|
||||
/* ── the send target ── */
|
||||
|
||||
/* A click anywhere in a transcript's body makes its channel the one Send
|
||||
message and Quick replies post to. The chosen one carries a 3px accent
|
||||
stripe along the bottom edge of the tile body — drawn just inside our
|
||||
root, since the tile chrome around it belongs to the shell. The others
|
||||
hint at it faintly on hover. */
|
||||
[data-module="slack"] .sl-transcript::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
[data-module="slack"] .sl-transcript:hover::after { opacity: 0.3; }
|
||||
[data-module="slack"] .sl-transcript.is-target::after { opacity: 1; }
|
||||
|
||||
/* ── transcript stream ── */
|
||||
|
||||
[data-module="slack"] .sl-stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 6px 10px 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[data-module="slack"] .sl-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
padding: 14% 10px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
|
||||
|
||||
[data-module="slack"] .sl-entry {
|
||||
padding: 5px 6px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
|
||||
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
|
||||
@keyframes sl-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* one row: the picture, then everything else (.sl-row — .sl-msg is the
|
||||
send tile's status line, a different thing) */
|
||||
[data-module="slack"] .sl-row,
|
||||
[data-module="slack"] .sl-reply {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-module="slack"] .sl-body { flex: 1; min-width: 0; }
|
||||
|
||||
/* the square: a profile image, a bot's icon or emoji, or initials on a tint
|
||||
of a hue the name hashes to — the same size and corner as ProdCom's */
|
||||
[data-module="slack"] .sl-avatar {
|
||||
flex: none;
|
||||
width: calc(var(--sl-size) * 1.9);
|
||||
height: calc(var(--sl-size) * 1.9);
|
||||
border-radius: 26%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--sl-hue, 150) 45% 50% / 0.2);
|
||||
color: hsl(var(--sl-hue, 150) 55% 60%);
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* "Avatars" off: the square goes and the row starts at the left edge, as in ProdCom */
|
||||
[data-module="slack"] .sl-wrap.hide-avatars .sl-avatar { display: none; }
|
||||
[data-module="slack"] .sl-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; }
|
||||
[data-module="slack"] .sl-reply .sl-avatar {
|
||||
width: calc(var(--sl-size) * 1.5);
|
||||
height: calc(var(--sl-size) * 1.5);
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
[data-module="slack"] .sl-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
[data-module="slack"] .sl-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.82em;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="slack"] .sl-bot .sl-name { color: var(--muted); }
|
||||
[data-module="slack"] .sl-time {
|
||||
flex: none;
|
||||
font-size: 0.72em;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="slack"] .sl-wrap:not(.show-times) .sl-time { display: none; }
|
||||
[data-module="slack"] .sl-wrap:not(.show-names) .sl-body > .sl-meta { display: none; }
|
||||
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-body > .sl-meta { display: flex; }
|
||||
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-name { display: none; }
|
||||
[data-module="slack"] .sl-wrap:not(.show-system) .sl-entry.sl-system { display: none; }
|
||||
|
||||
[data-module="slack"] .sl-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="slack"] .sl-text b { font-weight: 700; }
|
||||
[data-module="slack"] .sl-text i { font-style: italic; }
|
||||
[data-module="slack"] .sl-text s { color: var(--muted); }
|
||||
[data-module="slack"] .sl-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
[data-module="slack"] .sl-code.sl-block {
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
margin: 3px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
[data-module="slack"] .sl-edited {
|
||||
font-size: 0.75em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* a custom emoji is an image the size of a character */
|
||||
[data-module="slack"] .sl-emoji {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
object-fit: contain;
|
||||
vertical-align: -0.28em;
|
||||
}
|
||||
|
||||
/* reactions under a message: pills with the emoji and its count; the
|
||||
location's own carry the accent — a click adds yours or takes it back */
|
||||
[data-module="slack"] .sl-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
[data-module="slack"] .sl-reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 7px 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); }
|
||||
[data-module="slack"] .sl-reaction.is-me {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
[data-module="slack"] .sl-reaction .sl-emoji,
|
||||
[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; }
|
||||
[data-module="slack"] .sl-reaction-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); }
|
||||
|
||||
/* thread replies nest under their parent; without them, a muted count */
|
||||
[data-module="slack"] .sl-replies {
|
||||
margin: 4px 0 0 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
[data-module="slack"] .sl-reply { font-size: 0.92em; }
|
||||
[data-module="slack"] .sl-replycount {
|
||||
margin-top: 2px;
|
||||
font-size: 0.75em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* system lines: joins, leaves, topic changes */
|
||||
[data-module="slack"] .sl-sysline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 0.82em;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
[data-module="slack"] .sl-sysline .sl-time { font-style: normal; }
|
||||
|
||||
[data-module="slack"] .sl-jump {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10px;
|
||||
transform: translateX(-50%);
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* ── send / quick replies: the gate ── */
|
||||
|
||||
[data-module="slack"] .sl-gate,
|
||||
[data-module="slack"] .sl-compose {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-module="slack"] .sl-note {
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
|
||||
|
||||
/* The pad never shrinks below its content (min-height stays `auto` on it
|
||||
and on the key grid) — a shrunken grid would spill its rows under the
|
||||
message line, which then swallows the taps. A tile too short for it
|
||||
scrolls instead (.sl-gate has overflow: auto). */
|
||||
[data-module="slack"] .sl-pad {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 340px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
|
||||
@keyframes sl-shake {
|
||||
10%, 90% { transform: translateX(-2px); }
|
||||
20%, 80% { transform: translateX(3px); }
|
||||
30%, 50%, 70% { transform: translateX(-5px); }
|
||||
40%, 60% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
|
||||
[data-module="slack"] .sl-pad-head {
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
|
||||
|
||||
[data-module="slack"] .sl-dots {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 14px;
|
||||
}
|
||||
[data-module="slack"] .sl-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--muted);
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
[data-module="slack"] .sl-keys {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(4, minmax(24px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
[data-module="slack"] .sl-key {
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: clamp(13px, 9cqh, 26px);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-key:hover { background: var(--border); }
|
||||
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
|
||||
[data-module="slack"] .sl-key-back { color: var(--muted); }
|
||||
[data-module="slack"] .sl-key-blank { visibility: hidden; }
|
||||
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
[data-module="slack"] .sl-pad-msg {
|
||||
flex: none;
|
||||
min-height: 1.1em;
|
||||
text-align: center;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.1;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
||||
tiles) shows the dots and a hint instead — digits typed on the keyboard
|
||||
still land, and a taller tile brings the keys back. .sl-wrap is the size
|
||||
container the height is measured against. */
|
||||
[data-module="slack"] .sl-pad-hint {
|
||||
display: none;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.3;
|
||||
color: var(--muted);
|
||||
}
|
||||
@container (max-height: 176px) {
|
||||
[data-module="slack"] .sl-pad .sl-keys { display: none; }
|
||||
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
|
||||
[data-module="slack"] .sl-pad { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ── send / quick replies: unlocked ── */
|
||||
|
||||
[data-module="slack"] .sl-compose { gap: 6px; }
|
||||
|
||||
[data-module="slack"] .sl-compose-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-sending {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="slack"] .sl-lock-link {
|
||||
flex: none;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
|
||||
|
||||
[data-module="slack"] .sl-input {
|
||||
flex: 1;
|
||||
min-height: 2.6em;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
[data-module="slack"] .sl-compose-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 1.6em;
|
||||
}
|
||||
[data-module="slack"] .sl-msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
|
||||
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
|
||||
[data-module="slack"] .sl-countdown {
|
||||
flex: none;
|
||||
font-size: 0.75em;
|
||||
color: var(--warn);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="slack"] .sl-sendbtn {
|
||||
flex: none;
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
||||
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* quick emojis: one press each sends it as a message of its own */
|
||||
[data-module="slack"] .sl-emoji-sendrow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-send {
|
||||
flex: none;
|
||||
width: 2.1em;
|
||||
height: 2.1em;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
||||
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
|
||||
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
||||
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
|
||||
|
||||
/* the preset grid: big targets, labels wrap */
|
||||
[data-module="slack"] .sl-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
||||
grid-auto-rows: minmax(44px, auto);
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
|
||||
[data-module="slack"] .sl-quick {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||||
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
|
||||
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
|
||||
[data-module="slack"] .sl-quick.is-sent {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
|
||||
[data-module="slack"] .sl-quick-chan {
|
||||
font-size: 0.72em;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
|
||||
|
||||
/* ── the reaction menu (on document.body, so nothing clips it) ── */
|
||||
|
||||
[data-module="slack"].sl-emoji-menu {
|
||||
--sl-size: 14px;
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
width: 292px;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: var(--sl-size);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="slack"].sl-emoji-menu:focus { outline: none; }
|
||||
[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; }
|
||||
[data-module="slack"] .sl-emoji-head {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-quickrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-quickrow:empty { display: none; }
|
||||
[data-module="slack"] .sl-emoji-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); }
|
||||
[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); }
|
||||
[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; }
|
||||
[data-module="slack"] .sl-emoji-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); }
|
||||
[data-module="slack"] .sl-emoji-results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
min-height: 34px;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-none {
|
||||
width: 100%;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-msg {
|
||||
min-height: 1em;
|
||||
font-size: 0.78em;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
|
||||
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
|
||||
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
|
||||
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
|
||||
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* SPL Meter tiles.
|
||||
*
|
||||
* Four kinds, all fed by one SSE stream from the module's server:
|
||||
*
|
||||
* readout the number, big
|
||||
* bars one level bar per channel, with peak hold
|
||||
* rta spectrum now, folded to the banding this tile wants
|
||||
* spectrogram the same bands over time, either axis
|
||||
*
|
||||
* Nothing here measures anything. The capture child already sent every
|
||||
* averaging and the finest banding, and each tile folds that down to the
|
||||
* view it was configured for. That is the whole design: two dashboards can
|
||||
* watch one room through different settings without either disturbing the
|
||||
* measurement or the other dashboard.
|
||||
*/
|
||||
|
||||
const FLOOR = -200;
|
||||
|
||||
/**
|
||||
* A dead input reads as a number, and a number on a wall is believed. Every
|
||||
* tile therefore refuses to draw a level while the server says the input is
|
||||
* digitally silent, and says why instead.
|
||||
*/
|
||||
function silentFor(payload) {
|
||||
return payload && payload.silentSec ? payload.silentSec : 0;
|
||||
}
|
||||
|
||||
function silentText(sec) {
|
||||
if (sec < 90) return `No audio — ${sec} s`;
|
||||
const m = Math.round(sec / 60);
|
||||
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
|
||||
}
|
||||
|
||||
/** Retention per frame for the RTA's smoothing setting. */
|
||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const variant = moduleApi.variant || 'readout';
|
||||
let stream = null;
|
||||
let latest = null;
|
||||
let raf = 0;
|
||||
|
||||
root.classList.add('spl');
|
||||
root.dataset.variant = variant;
|
||||
|
||||
const view = buildView(variant, root, moduleApi);
|
||||
|
||||
function onState(payload) {
|
||||
latest = payload;
|
||||
if (payload.error) {
|
||||
moduleApi.setStatus('error', payload.error);
|
||||
} else if (!payload.frame) {
|
||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||
} else if (silentFor(payload)) {
|
||||
moduleApi.setStatus('error',
|
||||
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
|
||||
} else if (!payload.calibrated) {
|
||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||
} else {
|
||||
moduleApi.setStatus('ok', '');
|
||||
}
|
||||
// Frames land 20×/s; paint on a frame the browser was going to draw
|
||||
// anyway rather than fighting it for every one.
|
||||
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'connecting');
|
||||
moduleApi.fetch('/state')
|
||||
.then((r) => r.json())
|
||||
.then(onState)
|
||||
.catch(() => { /* the stream will bring it */ });
|
||||
// Named events go under `events` — a top-level `state(ev)` is silently
|
||||
// never registered, and the tile just sits on whatever /state gave it.
|
||||
stream = moduleApi.sse('/stream', {
|
||||
events: {
|
||||
state(ev) {
|
||||
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
|
||||
},
|
||||
},
|
||||
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
view.destroy();
|
||||
},
|
||||
onResize() { view.resize(); },
|
||||
};
|
||||
}
|
||||
|
||||
/* ── shared helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function pickChannel(payload, settings) {
|
||||
if (!payload || !payload.frame) return null;
|
||||
const want = Number(settings.channel);
|
||||
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level this tile asked for: an averaging, or one of the fixed metrics.
|
||||
*
|
||||
* Tiles saved by an earlier version carry metrics that no longer exist —
|
||||
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
|
||||
* must not start reading dashes because a setting was renamed, so those map
|
||||
* forward instead of falling through to undefined.
|
||||
*/
|
||||
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
|
||||
const LEGACY_AVERAGING = { leq: 'inf' };
|
||||
|
||||
function readValue(ch, settings) {
|
||||
const raw = settings.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
if (metric !== 'level') {
|
||||
return metric in ch ? ch[metric] : ch.fast;
|
||||
}
|
||||
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
|
||||
return avg in ch ? ch[avg] : ch.fast;
|
||||
}
|
||||
|
||||
function fmt(db) {
|
||||
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
||||
return db.toFixed(1);
|
||||
}
|
||||
|
||||
function themeColor(el, name, fallback) {
|
||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function hzLabel(f) {
|
||||
if (f >= 1000) {
|
||||
const k = f / 1000;
|
||||
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
|
||||
}
|
||||
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the 1/48-octave bands on the wire down to this tile's banding.
|
||||
*
|
||||
* Every banding offered divides 48, so a coarser band is an exact sum of
|
||||
* finer ones in the power domain — no interpolation, no resampling. Groups
|
||||
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
|
||||
* the values an RTA is expected to show.
|
||||
*
|
||||
* @returns {{freq:number[], db:number[]}}
|
||||
*/
|
||||
function foldBands(bands, bandN0, perOctave, denom) {
|
||||
const g = Math.max(1, Math.round(perOctave / denom));
|
||||
const power = new Map();
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
const n = bandN0 + i;
|
||||
const k = Math.round(n / g);
|
||||
const p = 10 ** (bands[i] / 10);
|
||||
power.set(k, (power.get(k) || 0) + p);
|
||||
}
|
||||
const keys = [...power.keys()].sort((a, b) => a - b);
|
||||
return {
|
||||
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
|
||||
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which frequency labels fit, most important first.
|
||||
*
|
||||
* Decades earn their place before octaves, octaves before thirds, and
|
||||
* anything that would collide with a label already placed is dropped. That
|
||||
* is what makes the axis thin out as a tile shrinks instead of turning into
|
||||
* a smear.
|
||||
*/
|
||||
const LABEL_TIERS = [
|
||||
[100, 1000, 10000],
|
||||
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
|
||||
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
|
||||
];
|
||||
|
||||
function chooseLabels(freqs, positionOf, extent, minGapPx) {
|
||||
const placed = [];
|
||||
for (const tier of LABEL_TIERS) {
|
||||
for (const f of tier) {
|
||||
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
|
||||
const pos = positionOf(f);
|
||||
if (pos < 0 || pos > extent) continue;
|
||||
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
|
||||
placed.push({ f, pos });
|
||||
}
|
||||
}
|
||||
return placed.sort((a, b) => a.pos - b.pos);
|
||||
}
|
||||
|
||||
/* ── views ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function buildView(variant, root, moduleApi) {
|
||||
switch (variant) {
|
||||
case 'bars': return barsView(root, moduleApi);
|
||||
case 'rta': return spectrumView(root, moduleApi, false);
|
||||
case 'spectrogram': return spectrumView(root, moduleApi, true);
|
||||
default: return readoutView(root, moduleApi);
|
||||
}
|
||||
}
|
||||
|
||||
function readoutView(root, moduleApi) {
|
||||
root.innerHTML = `
|
||||
<div class="spl-readout">
|
||||
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
||||
<div class="spl-sub"></div>
|
||||
</div>`;
|
||||
const num = root.querySelector('.spl-num');
|
||||
const unit = root.querySelector('.spl-unit');
|
||||
const sub = root.querySelector('.spl-sub');
|
||||
const box = root.querySelector('.spl-readout');
|
||||
const value = root.querySelector('.spl-value');
|
||||
let fitKey = '';
|
||||
|
||||
/**
|
||||
* Grow the number until it fills the tile, measuring the text we actually
|
||||
* have rather than assuming a digit count.
|
||||
*
|
||||
* The CSS can only guess at width — it has to pick a cqw coefficient for
|
||||
* some number of characters, and then "-100.5" overflows the tile that
|
||||
* "-45.9" merely filled. Measuring costs one layout read, and only when
|
||||
* the tile size or the length of the reading changes.
|
||||
*/
|
||||
function fit() {
|
||||
const w = root.clientWidth, h = root.clientHeight;
|
||||
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
|
||||
if (key === fitKey || w < 2 || h < 2) return;
|
||||
fitKey = key;
|
||||
|
||||
const TRIAL = 100;
|
||||
num.style.fontSize = `${TRIAL}px`;
|
||||
unit.style.fontSize = `${TRIAL * 0.26}px`;
|
||||
const natural = value.scrollWidth || 1;
|
||||
|
||||
// Leave the caption its room when it is showing, and a little air either
|
||||
// way so descenders and the tile's padding never clip.
|
||||
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
|
||||
const byHeight = (h - subH) * 0.92;
|
||||
const byWidth = ((w - 8) * TRIAL) / natural;
|
||||
const size = Math.max(12, Math.min(byHeight, byWidth));
|
||||
num.style.fontSize = `${size}px`;
|
||||
unit.style.fontSize = `${size * 0.26}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const ch = pickChannel(payload, s);
|
||||
const quiet = silentFor(payload);
|
||||
if (quiet && ch) {
|
||||
num.textContent = '––.–';
|
||||
unit.textContent = '';
|
||||
sub.hidden = false;
|
||||
sub.textContent = silentText(quiet);
|
||||
box.dataset.level = 'dead';
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
if (!payload || payload.error || !ch) {
|
||||
num.textContent = '––.–';
|
||||
sub.textContent = payload && payload.error ? payload.error : '';
|
||||
box.dataset.level = 'idle';
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
const value = readValue(ch, s);
|
||||
num.textContent = fmt(value);
|
||||
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
||||
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
box.dataset.level = !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
|
||||
const raw = s.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
|
||||
const label = metric === 'level'
|
||||
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
|
||||
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
|
||||
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
|
||||
if (ch.clip) parts.push('CLIP');
|
||||
if (!payload.calibrated) parts.push('uncalibrated');
|
||||
sub.hidden = s.caption === false;
|
||||
sub.textContent = parts.join(' · ');
|
||||
fit();
|
||||
},
|
||||
resize() { fitKey = ''; fit(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
function barsView(root, moduleApi) {
|
||||
root.innerHTML = '<div class="spl-bars"></div>';
|
||||
const host = root.querySelector('.spl-bars');
|
||||
let built = '';
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
|
||||
const chans = payload.frame.ch;
|
||||
const key = chans.map((c) => c.i).join(',');
|
||||
if (key !== built) {
|
||||
built = key;
|
||||
host.innerHTML = chans.map((c) => `
|
||||
<div class="spl-bar" data-ch="${c.i}">
|
||||
<div class="spl-bar-label">${c.i}</div>
|
||||
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
|
||||
<div class="spl-bar-value">––.–</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
const s = moduleApi.instanceSettings;
|
||||
const quiet = silentFor(payload);
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
const showPeak = s.peakHold !== false;
|
||||
const lo = payload.calibrated ? 40 : -80;
|
||||
const hi = payload.calibrated ? 110 : 0;
|
||||
for (const c of chans) {
|
||||
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
|
||||
if (!el) continue;
|
||||
const value = readValue(c, { averaging: s.averaging });
|
||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
|
||||
const peak = el.querySelector('.spl-bar-peak');
|
||||
peak.style.display = showPeak ? '' : 'none';
|
||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
|
||||
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
},
|
||||
resize() {},
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RTA bars, or the same folded bands scrolling as a spectrogram.
|
||||
*
|
||||
* Canvas for both, because both redraw wholesale many times a second and a
|
||||
* few hundred animating DOM nodes is how a dashboard starts dropping frames
|
||||
* on the machine that is also running the service.
|
||||
*/
|
||||
function spectrumView(root, moduleApi, scrolling) {
|
||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||
const canvas = root.querySelector('.spl-canvas');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
/**
|
||||
* The spectrogram's scrolling history, on its own surface.
|
||||
*
|
||||
* It used to scroll by copying the visible canvas onto itself, which also
|
||||
* copied whatever had been drawn on top of it — so every frame dragged the
|
||||
* frequency labels and gridlines one step further along and left a smear.
|
||||
* Only measured pixels belong on a surface that scrolls; annotations are
|
||||
* drawn fresh over the top of it, every frame, and never into it.
|
||||
*/
|
||||
let history = null;
|
||||
let historyCtx = null;
|
||||
let painted = false; // spectrogram: has the history been cleared yet
|
||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||
let smoothedFor = '';
|
||||
let axisBtn = null;
|
||||
let axisOverride = null; // set by the title-bar toggle, which never remounts us
|
||||
|
||||
function axis() {
|
||||
return axisOverride || moduleApi.instanceSettings.axis || 'x';
|
||||
}
|
||||
|
||||
if (scrolling) {
|
||||
// The axis lives here and nowhere else (rule 11): a tile does not offer
|
||||
// the same action twice. Icon only, tooltip carries the words (rule 9).
|
||||
// saveInstanceSettings deliberately does not remount us, so the new
|
||||
// value is held here as well and read back by axis().
|
||||
const swapIcon = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
|
||||
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
|
||||
axisBtn = moduleApi.header.addButton({
|
||||
icon: swapIcon,
|
||||
title: describe(),
|
||||
onClick() {
|
||||
axisOverride = axis() === 'x' ? 'y' : 'x';
|
||||
moduleApi.saveInstanceSettings({ axis: axisOverride });
|
||||
painted = false; // the picture means nothing in the other orientation
|
||||
axisBtn.title = describe();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(root.clientWidth));
|
||||
const h = Math.max(1, Math.floor(root.clientHeight));
|
||||
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
painted = false;
|
||||
return true;
|
||||
}
|
||||
sizeCanvas();
|
||||
|
||||
function norm(db, calibrated) {
|
||||
const lo = calibrated ? 30 : -100;
|
||||
const hi = calibrated ? 110 : -10;
|
||||
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
||||
}
|
||||
|
||||
function heat(v) {
|
||||
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
||||
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
||||
const i = Math.floor(x), t = x - i;
|
||||
const a = stops[i], b = stops[i + 1] || stops[i];
|
||||
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
sizeCanvas();
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
const bg = themeColor(root, '--panel', '#141414');
|
||||
const muted = themeColor(root, '--muted', '#888');
|
||||
const border = themeColor(root, '--border', '#333');
|
||||
|
||||
const quiet = silentFor(payload);
|
||||
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(quiet ? silentText(quiet)
|
||||
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
|
||||
painted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = moduleApi.instanceSettings;
|
||||
const want = Number(s.channel);
|
||||
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
||||
const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||
if (!raw.length) return;
|
||||
|
||||
const denom = Number(s.banding) || 3;
|
||||
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
|
||||
const freqs = folded.freq;
|
||||
let values = folded.db;
|
||||
const grid = s.gridlines === true;
|
||||
const showLabels = s.labels !== false;
|
||||
const axisPad = showLabels || grid ? 13 : 0;
|
||||
|
||||
if (!scrolling) {
|
||||
// Damp the bars toward the measurement. Cosmetic: `values` is what
|
||||
// gets drawn, never what gets reported.
|
||||
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
|
||||
const key = `${denom}:${values.length}`;
|
||||
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
|
||||
smoothed = values.slice();
|
||||
smoothedFor = key;
|
||||
} else if (keep > 0) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
|
||||
}
|
||||
} else {
|
||||
smoothed = values.slice();
|
||||
}
|
||||
values = smoothed;
|
||||
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const plotH = h - axisPad;
|
||||
const bw = w / values.length;
|
||||
|
||||
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
|
||||
const labels = chooseLabels(freqs, xOf, w, 34);
|
||||
|
||||
if (grid) {
|
||||
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||
}
|
||||
for (let g = 0.2; g < 1; g += 0.2) {
|
||||
const y = Math.round(plotH * g) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = norm(values[i], payload.calibrated);
|
||||
const bh = v * plotH;
|
||||
ctx.fillStyle = heat(v);
|
||||
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
||||
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── spectrogram ──
|
||||
const timeX = axis() === 'x';
|
||||
const colW = 2;
|
||||
// A label along the bottom needs only its line height; one down the
|
||||
// side has to fit "12.5k" beside the plot rather than on top of it.
|
||||
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
|
||||
// Labels belong beside the frequency axis: down the left when frequency
|
||||
// runs vertically, along the bottom when it runs horizontally.
|
||||
const plotX = timeX ? gutter : 0;
|
||||
const plotW = timeX ? w - gutter : w;
|
||||
const plotH = timeX ? h : h - gutter;
|
||||
const freqExtent = timeX ? plotH : plotW;
|
||||
const timeExtent = timeX ? plotW : plotH;
|
||||
if (plotW < 4 || plotH < 4) return;
|
||||
|
||||
// The history surface is sized to the plot, in device pixels, and is
|
||||
// thrown away whenever that geometry changes — a scroll buffer means
|
||||
// nothing once it is being stretched into a different shape.
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const needW = Math.max(1, Math.round(plotW * dpr));
|
||||
const needH = Math.max(1, Math.round(plotH * dpr));
|
||||
if (!history || history.width !== needW || history.height !== needH) {
|
||||
history = document.createElement('canvas');
|
||||
history.width = needW;
|
||||
history.height = needH;
|
||||
historyCtx = history.getContext('2d', { alpha: false });
|
||||
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
painted = false;
|
||||
}
|
||||
if (!painted) {
|
||||
historyCtx.fillStyle = bg;
|
||||
historyCtx.fillRect(0, 0, plotW, plotH);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
// Scroll the history, then add the newest column. The source rectangle
|
||||
// is in device pixels and the destination in CSS pixels, because the
|
||||
// context carries the dpr transform but the canvas as an image source
|
||||
// does not.
|
||||
historyCtx.drawImage(history, 0, 0, history.width, history.height,
|
||||
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
|
||||
|
||||
const bandExtent = freqExtent / values.length;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||
if (timeX) {
|
||||
// Low frequencies at the bottom, as every spectrogram has them.
|
||||
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||
colW, Math.ceil(bandExtent) + 1);
|
||||
} else {
|
||||
// Low frequencies at the left.
|
||||
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
|
||||
Math.ceil(bandExtent) + 1, colW);
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint the visible canvas from scratch every frame: history first,
|
||||
// annotations over it. Nothing drawn here can ever reach the scroll.
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(history, 0, 0, history.width, history.height,
|
||||
plotX, 0, plotW, plotH);
|
||||
|
||||
if (!grid && !showLabels) return;
|
||||
|
||||
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||
const posOf = (f) => {
|
||||
const t = Math.log2(f / freqs[0]) / span;
|
||||
return timeX ? plotH - t * plotH : plotX + t * plotW;
|
||||
};
|
||||
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
|
||||
|
||||
if (grid) {
|
||||
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
const y = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
|
||||
} else {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||
ctx.textAlign = timeX ? 'right' : 'center';
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
ctx.fillText(hzLabel(l.f), gutter - 2,
|
||||
Math.max(7, Math.min(h - 2, l.pos + 3)));
|
||||
} else {
|
||||
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
resize() { sizeCanvas(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.5.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "microphone",
|
||||
"reason": "Measuring sound level from an audio input on this machine"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 3,
|
||||
"h": 2
|
||||
},
|
||||
"configGroups": {
|
||||
"Input": {
|
||||
"toggle": "enabled"
|
||||
},
|
||||
"Measurement": {
|
||||
"columns": 2
|
||||
},
|
||||
"Calibration": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"enabled": {
|
||||
"type": "switch",
|
||||
"label": "Sound level measurement",
|
||||
"group": "Input",
|
||||
"default": false
|
||||
},
|
||||
"device": {
|
||||
"type": "select",
|
||||
"label": "Audio device",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "",
|
||||
"optionsRoute": "/devices"
|
||||
},
|
||||
"channels": {
|
||||
"type": "string",
|
||||
"label": "Channels on that device (1-based, comma separated)",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "1",
|
||||
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
|
||||
},
|
||||
"weighting": {
|
||||
"type": "select",
|
||||
"label": "Weighting",
|
||||
"group": "Measurement",
|
||||
"default": "A",
|
||||
"options": [
|
||||
{
|
||||
"value": "A",
|
||||
"label": "A — how loud it sounds"
|
||||
},
|
||||
{
|
||||
"value": "C",
|
||||
"label": "C — keeps the low end"
|
||||
},
|
||||
{
|
||||
"value": "Z",
|
||||
"label": "Z — no weighting at all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bands": {
|
||||
"type": "switch",
|
||||
"label": "Spectrum analysis",
|
||||
"group": "Measurement",
|
||||
"default": true,
|
||||
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
|
||||
},
|
||||
"calibration": {
|
||||
"type": "string",
|
||||
"label": "Offsets in dB, one per channel",
|
||||
"group": "Calibration",
|
||||
"default": "",
|
||||
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/zsh
|
||||
# Build this module's native helper for every macOS architecture it ships —
|
||||
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
|
||||
# from spl-capture.swift beside this script. Run it on any Mac with the
|
||||
# Xcode command line tools; both architectures cross-compile from either.
|
||||
# The binaries are committed: a machine running ProdDash never builds
|
||||
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||
#
|
||||
# Signed with a Developer ID Application certificate when one is installed
|
||||
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||
# entitlement, which a helper that reads a microphone needs under the
|
||||
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||
#
|
||||
# Windows is not built here: there is no Windows implementation of this
|
||||
# tool yet, so on Windows the module reports measurement off.
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
NAME="spl-capture"
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
command -v swiftc >/dev/null || {
|
||||
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
fi
|
||||
if [[ -n "$IDENTITY" ]]; then
|
||||
echo "signing as $IDENTITY"
|
||||
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||
else
|
||||
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
|
||||
SIGN=(--force --sign - --options runtime)
|
||||
fi
|
||||
|
||||
mkdir -p ../bin
|
||||
for ARCH in arm64 x86_64; do
|
||||
OUT="../bin/$NAME-macos-$ARCH"
|
||||
echo " compiling $OUT"
|
||||
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||
codesign --verify --strict "$OUT"
|
||||
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||
done
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The helper reads an audio input; under the hardened runtime that
|
||||
needs saying. The permission itself comes from the launcher. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,736 @@
|
||||
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
|
||||
//
|
||||
// Sibling of the Timers module's ltc-capture: same device enumeration, same
|
||||
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
|
||||
// rule. The difference is where the work happens. LTC ships raw samples to
|
||||
// Node because the decode is cheap and single-channel; sound level is
|
||||
// filtering and FFTs over every channel at the device's rate, so the
|
||||
// measurement is done here and only the answers cross the pipe.
|
||||
//
|
||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||
// code, no package manager. It ships prebuilt in the module's bin/ (one
|
||||
// binary per platform, committed — a booth never builds anything). To
|
||||
// rebuild after a change here, on any Mac with the Xcode command line tools:
|
||||
//
|
||||
// modules/spl-meter/native/build.sh (both architectures)
|
||||
//
|
||||
// spl-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
|
||||
// measures those 1-based channels and writes one JSON line per frame
|
||||
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
|
||||
// order, from the calibration route (94 dB reference minus what we
|
||||
// measured). With no --cal the numbers are dBFS-relative and the line
|
||||
// says calibrated:false, so nothing downstream can mistake them for
|
||||
// real SPL.
|
||||
//
|
||||
// Per channel, per frame: the level at EVERY averaging a dashboard might
|
||||
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
|
||||
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
|
||||
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
|
||||
// and spectrogram tiles, only when --bands is passed.
|
||||
//
|
||||
// Everything is sent every frame on purpose. Two dashboards watching the
|
||||
// same room may want different averaging and different banding, and neither
|
||||
// choice may disturb the other or restart capture. So the measurement is
|
||||
// made once, at the finest resolution anyone asked for, and each tile folds
|
||||
// it down to the view it wants. Nothing here is per-viewer state.
|
||||
//
|
||||
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
|
||||
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
|
||||
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
|
||||
// 4 kHz. The top octave is where the bilinear transform runs out of room —
|
||||
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
|
||||
// on a broadband programme number (A-weighting has already thrown 6.6 dB
|
||||
// away up there) but it is a real limit, and the fix if it ever matters is
|
||||
// to run the weighting at 96 kHz. `--selftest` prints the table so this
|
||||
// claim can be checked rather than believed.
|
||||
//
|
||||
// macOS asks for microphone permission on first use, and the prompt belongs
|
||||
// to whatever launched this — the ProdDash launcher, or Terminal. A server
|
||||
// started over SSH gets SILENT ZEROS from every input with no error at all,
|
||||
// which shows up downstream as a meter pinned at the floor.
|
||||
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
func fail(_ msg: String) -> Never {
|
||||
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func note(_ msg: String) {
|
||||
FileHandle.standardError.write(("spl-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
|
||||
}
|
||||
|
||||
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
|
||||
|
||||
/// Pole frequencies from the standard.
|
||||
private let wf1 = 2 * Double.pi * 20.598997
|
||||
private let wf2 = 2 * Double.pi * 107.65265
|
||||
private let wf3 = 2 * Double.pi * 737.86223
|
||||
private let wf4 = 2 * Double.pi * 12194.217
|
||||
|
||||
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
|
||||
///
|
||||
/// `warpAt` prewarps the transform so that section's critical frequency
|
||||
/// lands where the analogue prototype put it. Without it the plain
|
||||
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
|
||||
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
|
||||
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
|
||||
fs: Double, warpAt: Double = 0) -> [Double] {
|
||||
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
|
||||
let c2 = c * c
|
||||
let B0 = b.0 * c2 + b.1 * c + b.2
|
||||
let B1 = 2 * b.2 - 2 * b.0 * c2
|
||||
let B2 = b.0 * c2 - b.1 * c + b.2
|
||||
let A0 = a.0 * c2 + a.1 * c + a.2
|
||||
let A1 = 2 * a.2 - 2 * a.0 * c2
|
||||
let A2 = a.0 * c2 - a.1 * c + a.2
|
||||
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
|
||||
}
|
||||
|
||||
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
|
||||
/// and to print the verification table.
|
||||
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
|
||||
let th = 2 * Double.pi * f / fs
|
||||
var reT = 1.0, imT = 0.0
|
||||
for s in stride(from: 0, to: secs.count, by: 5) {
|
||||
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
|
||||
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
|
||||
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
|
||||
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
|
||||
let di = -(a1 * sin(th) + a2 * sin(2 * th))
|
||||
let dd = dr * dr + di * di
|
||||
guard dd > 0 else { return 0 }
|
||||
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
|
||||
let t = reT * qr - imT * qi
|
||||
imT = reT * qi + imT * qr
|
||||
reT = t
|
||||
}
|
||||
return (reT * reT + imT * imT).squareRoot()
|
||||
}
|
||||
|
||||
enum Weighting: String {
|
||||
case a = "A", c = "C", z = "Z"
|
||||
|
||||
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
|
||||
func sections(fs: Double) -> [Double] {
|
||||
var s: [Double]
|
||||
switch self {
|
||||
case .a:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .c:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .z:
|
||||
return [1, 0, 0, 0, 0] // flat, one pass-through section
|
||||
}
|
||||
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
|
||||
s[0] *= g; s[1] *= g; s[2] *= g
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/// A weighting filter bound to one channel. The coefficients could be shared
|
||||
/// but the delay line cannot, and a setup per channel costs a few hundred
|
||||
/// bytes — not worth the aliasing risk of sharing one.
|
||||
final class WeightingFilter {
|
||||
private let setup: vDSP_biquad_Setup
|
||||
private var delay: [Float]
|
||||
private let sections: vDSP_Length
|
||||
|
||||
init(_ weighting: Weighting, fs: Double) {
|
||||
let coeffs = weighting.sections(fs: fs)
|
||||
sections = vDSP_Length(coeffs.count / 5)
|
||||
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
|
||||
fail("cannot build \(weighting.rawValue)-weighting filter")
|
||||
}
|
||||
setup = s
|
||||
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
|
||||
}
|
||||
|
||||
deinit { vDSP_biquad_DestroySetup(setup) }
|
||||
|
||||
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
|
||||
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── third-octave band plan for the RTA ─────────────────────────────── */
|
||||
|
||||
/// The wire always carries 1/48-octave bands, and a tile folds them down to
|
||||
/// whatever banding it wants to draw.
|
||||
///
|
||||
/// That works exactly because every banding the dashboard offers divides 48:
|
||||
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
|
||||
/// are additive, so a fold is a sum and nothing is approximated. The
|
||||
/// alternative — the capture re-banding on request — would mean one dashboard
|
||||
/// choosing 1/6 changes what every other dashboard sees.
|
||||
///
|
||||
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
|
||||
/// the folded centres on the standard values.
|
||||
let bandN0 = -271 // ≈ 20 Hz
|
||||
let bandN1 = 207 // ≈ 20 kHz
|
||||
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
|
||||
|
||||
/// FFT bin ranges per band, clamped to what the transform can actually
|
||||
/// resolve.
|
||||
///
|
||||
/// This is the one caveat a reader of the RTA needs and the gear is not
|
||||
/// allowed to carry (module rule 5, no explanatory text in a tile's
|
||||
/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one
|
||||
/// bin, so neighbouring bands start sharing bins and the low end reads
|
||||
/// smoother than it really is. That is the window length talking, not the
|
||||
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
|
||||
/// transform is the real fix if the bottom octaves ever have to be read at
|
||||
/// full resolution.
|
||||
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
||||
let binHz = fs / Double(fftSize)
|
||||
let maxBin = fftSize / 2 - 1
|
||||
return bandCentres.map { fc in
|
||||
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
|
||||
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── per-channel measurement state ──────────────────────────────────── */
|
||||
|
||||
/// Everything the standard asks for on one channel.
|
||||
///
|
||||
/// SPL is an exponential moving average of weighted mean square — Fast is a
|
||||
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
|
||||
/// a plain energy average with no time constant at all, which is why it is
|
||||
/// accumulated separately rather than read off the integrator.
|
||||
final class ChannelMeter {
|
||||
let index: Int // 1-based, as the operator counts them
|
||||
private let fs: Double
|
||||
private let calibration: Double // dB added to turn dBFS into dB SPL
|
||||
|
||||
private let weightFilter: WeightingFilter
|
||||
private let cFilter: WeightingFilter
|
||||
private var weighted: [Float]
|
||||
private var cWeighted: [Float]
|
||||
|
||||
// One integrator per averaging the dashboard might ask for. They all run
|
||||
// all the time and all go on the wire: a tile choosing "Slow" must not
|
||||
// restart capture, because the next tile wants "Fast" off the same
|
||||
// stream and a third wants Inf. Four extra doubles per channel is a
|
||||
// rounding error next to the filtering; re-measuring per viewer is not.
|
||||
private var msFast: Double = 0
|
||||
private var msSlow: Double = 0
|
||||
private var msNone: Double = 0 // this block only, no time constant
|
||||
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
|
||||
private let alphaFast: Double
|
||||
private let alphaSlow: Double
|
||||
|
||||
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
|
||||
private var leqSum: Double = 0
|
||||
private var leqCount: Double = 0
|
||||
private var ring: [Double] = [] // one mean-square per emitted frame
|
||||
private let ringCapacity: Int
|
||||
|
||||
private var peakC: Double = 0 // linear, since reset
|
||||
private var peakZ: Double = 0
|
||||
private var clipped = false
|
||||
|
||||
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
||||
framesPerSecond: Double) {
|
||||
self.index = index
|
||||
self.fs = fs
|
||||
self.calibration = calibration
|
||||
weightFilter = WeightingFilter(weighting, fs: fs)
|
||||
cFilter = WeightingFilter(.c, fs: fs)
|
||||
weighted = []
|
||||
cWeighted = []
|
||||
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
|
||||
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
|
||||
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
|
||||
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
|
||||
}
|
||||
|
||||
/// Feed one tap buffer for this channel.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if weighted.count < n {
|
||||
weighted = [Float](repeating: 0, count: n)
|
||||
cWeighted = [Float](repeating: 0, count: n)
|
||||
}
|
||||
weightFilter.process(samples, &weighted, n)
|
||||
cFilter.process(samples, &cWeighted, n)
|
||||
|
||||
// Exponential integration over the weighted signal, sample by sample
|
||||
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
|
||||
// this is a few hundred thousand multiply-adds a second — noise next
|
||||
// to the filtering above.
|
||||
var fast = msFast, slow = msSlow, flat = flatMeanSquare
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
for i in 0..<n {
|
||||
let v = Double(w[i]) * Double(w[i])
|
||||
fast += alphaFast * (v - fast)
|
||||
slow += alphaSlow * (v - slow)
|
||||
let r = Double(samples[i])
|
||||
flat += alphaFast * (r * r - flat)
|
||||
}
|
||||
}
|
||||
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
||||
|
||||
// Mean square of just this block — "None", the reading with no time
|
||||
// constant at all. Also what Leq accumulates, since Leq is a plain
|
||||
// energy average and has no time constant either.
|
||||
var sumSq: Float = 0
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||||
}
|
||||
msNone = Double(sumSq)
|
||||
leqSum += Double(sumSq) * Double(n)
|
||||
leqCount += Double(n)
|
||||
|
||||
var cPeak: Float = 0, zPeak: Float = 0
|
||||
cWeighted.withUnsafeBufferPointer { c in
|
||||
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
|
||||
}
|
||||
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
|
||||
peakC = max(peakC, Double(cPeak))
|
||||
peakZ = max(peakZ, Double(zPeak))
|
||||
if zPeak >= 0.999 { clipped = true }
|
||||
}
|
||||
|
||||
/// dB SPL from a linear mean-square, with the calibration offset applied.
|
||||
private func db(_ power: Double) -> Double {
|
||||
20 * log10(max(power, 1e-20).squareRoot()) + calibration
|
||||
}
|
||||
|
||||
/// Called once per emitted frame; also advances the rolling Leq window.
|
||||
///
|
||||
/// Returns a JSON object built by hand. JSONSerialization renders a
|
||||
/// rounded Double as `-70.120000000000005`, which is both ugly on the
|
||||
/// wire and noise in a diff — `%.2f` says what a meter can actually
|
||||
/// resolve and nothing more.
|
||||
func snapshot() -> String {
|
||||
ring.append(msNone)
|
||||
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
|
||||
|
||||
let fps = Double(ringCapacity) / 600.0
|
||||
func rollingLeq(seconds: Double) -> Double {
|
||||
let want = max(1, Int((seconds * fps).rounded()))
|
||||
let slice = ring.suffix(want)
|
||||
guard !slice.isEmpty else { return -200 }
|
||||
return db(slice.reduce(0, +) / Double(slice.count))
|
||||
}
|
||||
|
||||
return "{\"i\":\(index)"
|
||||
+ f("fast", db(msFast))
|
||||
+ f("slow", db(msSlow))
|
||||
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ f("none", db(msNone))
|
||||
+ f("leq1m", rollingLeq(seconds: 60))
|
||||
+ f("leq10m", rollingLeq(seconds: 600))
|
||||
+ f("peakC", db(peakC * peakC))
|
||||
+ f("peakZ", db(peakZ * peakZ))
|
||||
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
|
||||
+ ",\"clip\":\(clipped)}"
|
||||
}
|
||||
|
||||
/// Clears Leq, peaks and the clip flag. The exponential integrator is
|
||||
/// deliberately left alone — it is a live reading, not an accumulation,
|
||||
/// and zeroing it would just make the meter dive for a moment.
|
||||
func reset() {
|
||||
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
|
||||
peakC = 0; peakZ = 0; clipped = false
|
||||
}
|
||||
}
|
||||
|
||||
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
|
||||
/// silent input can never put a non-number on the wire.
|
||||
func f(_ key: String, _ v: Double) -> String {
|
||||
",\"\(key)\":" + num(v)
|
||||
}
|
||||
|
||||
func num(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200.00" }
|
||||
return String(format: "%.2f", max(v, -200))
|
||||
}
|
||||
|
||||
/// One decimal, for band arrays. 479 bands per channel per frame is the
|
||||
/// bulk of the wire; a second decimal on a bar nobody can read that finely
|
||||
/// is 15% of the bandwidth for nothing.
|
||||
func num1(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200" }
|
||||
return String(format: "%.1f", max(v, -200))
|
||||
}
|
||||
|
||||
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
|
||||
|
||||
final class Spectrum {
|
||||
private let fftSize: Int
|
||||
private let log2n: vDSP_Length
|
||||
private let setup: FFTSetup
|
||||
private var window: [Float]
|
||||
private var accum: [Float] // sliding input buffer
|
||||
private var filled = 0
|
||||
private let bins: [(Int, Int)]
|
||||
private let windowPower: Double
|
||||
|
||||
init(fs: Double, fftSize: Int) {
|
||||
self.fftSize = fftSize
|
||||
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
|
||||
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
|
||||
fail("cannot create FFT setup")
|
||||
}
|
||||
setup = s
|
||||
window = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
|
||||
accum = [Float](repeating: 0, count: fftSize)
|
||||
bins = bandBins(fs: fs, fftSize: fftSize)
|
||||
// Coherent gain of the Hann window, so band levels are not 6 dB low.
|
||||
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
|
||||
}
|
||||
|
||||
deinit { vDSP_destroy_fftsetup(setup) }
|
||||
|
||||
/// Keep the most recent `fftSize` samples, shifting the window along.
|
||||
/// Buffers larger than the window just overwrite it outright.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if n >= fftSize {
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
|
||||
}
|
||||
filled = fftSize
|
||||
return
|
||||
}
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
let base = a.baseAddress!
|
||||
// Slide the old tail down, then append the new block.
|
||||
base.update(from: base + n, count: fftSize - n)
|
||||
(base + fftSize - n).update(from: samples, count: n)
|
||||
}
|
||||
filled = min(fftSize, filled + n)
|
||||
}
|
||||
|
||||
/// Third-octave band levels in dB relative to full scale, plus the
|
||||
/// calibration offset applied by the caller.
|
||||
func bands(calibration: Double) -> [Double] {
|
||||
guard filled >= fftSize else { return bins.map { _ in -200 } }
|
||||
var windowed = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
|
||||
|
||||
let half = fftSize / 2
|
||||
var re = [Float](repeating: 0, count: half)
|
||||
var im = [Float](repeating: 0, count: half)
|
||||
var mag = [Float](repeating: 0, count: half)
|
||||
re.withUnsafeMutableBufferPointer { rp in
|
||||
im.withUnsafeMutableBufferPointer { ip in
|
||||
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
|
||||
windowed.withUnsafeBytes { raw in
|
||||
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
|
||||
&split, 1, vDSP_Length(half))
|
||||
}
|
||||
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
|
||||
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
|
||||
}
|
||||
}
|
||||
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
|
||||
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
|
||||
return bins.map { (lo, hi) in
|
||||
var power = 0.0
|
||||
for b in lo...hi { power += Double(mag[b]) }
|
||||
power *= scale * 2.0 // one-sided spectrum
|
||||
return 10 * log10(max(power, 1e-20)) + calibration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── args ───────────────────────────────────────────────────────────── */
|
||||
|
||||
var wantList = false
|
||||
var wantSelftest = false
|
||||
var wantDevice: String? = nil
|
||||
var wantChannels: [Int] = [1]
|
||||
var wantCal: [Double] = []
|
||||
var wantWeighting = Weighting.a
|
||||
var wantBands = false
|
||||
var framesPerSecond = 20.0
|
||||
|
||||
let argv = CommandLine.arguments
|
||||
var argIdx = 1
|
||||
func nextArg(_ flag: String) -> String {
|
||||
argIdx += 1
|
||||
guard argIdx < argv.count else { fail("\(flag) needs a value") }
|
||||
return argv[argIdx]
|
||||
}
|
||||
while argIdx < argv.count {
|
||||
switch argv[argIdx] {
|
||||
case "--list": wantList = true
|
||||
case "--selftest": wantSelftest = true
|
||||
case "--device": wantDevice = nextArg("--device")
|
||||
case "--channels":
|
||||
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
|
||||
case "--cal":
|
||||
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||
case "--weighting":
|
||||
let raw = nextArg("--weighting").uppercased()
|
||||
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
|
||||
wantWeighting = w
|
||||
case "--bands": wantBands = true
|
||||
case "--rate":
|
||||
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
|
||||
framesPerSecond = r
|
||||
default: fail("unknown flag \(argv[argIdx]) — try --list")
|
||||
}
|
||||
argIdx += 1
|
||||
}
|
||||
|
||||
/* ── selftest: prove the weighting curves before trusting a reading ─── */
|
||||
|
||||
if wantSelftest {
|
||||
// IEC 61672-1 table 3, nominal A- and C-weighting.
|
||||
let table: [(Double, Double, Double)] = [
|
||||
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
|
||||
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
|
||||
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
|
||||
]
|
||||
for fs in [48000.0, 96000.0] {
|
||||
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
|
||||
print("— \(Int(fs)) Hz —")
|
||||
print(" Hz A IEC err C IEC err")
|
||||
var worstA = 0.0, worstC = 0.0
|
||||
for (f, wantA, wantC) in table where f < fs / 2 {
|
||||
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
|
||||
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
|
||||
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
|
||||
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
|
||||
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
|
||||
}
|
||||
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
/* ── device selection ───────────────────────────────────────────────── */
|
||||
|
||||
let devices = listInputDevices()
|
||||
|
||||
if wantList || wantDevice == nil {
|
||||
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||
// stdout, as JSON: the module's /devices route feeds this straight to
|
||||
// the admin page's device picker.
|
||||
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
|
||||
"channels": $0.channels, "sampleRate": $0.sampleRate] }
|
||||
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||
exit(wantList ? 0 : 2)
|
||||
}
|
||||
|
||||
let query = wantDevice!.lowercased()
|
||||
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 ────────────────────────────────────────────────────────── */
|
||||
|
||||
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 channelCount = Int(format.channelCount)
|
||||
let sampleRate = format.sampleRate
|
||||
guard channelCount > 0, sampleRate > 0 else {
|
||||
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||
+ "(System Settings → Privacy & Security → Microphone)")
|
||||
}
|
||||
for ch in wantChannels where ch < 1 || ch > channelCount {
|
||||
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
|
||||
}
|
||||
|
||||
let calibrated = !wantCal.isEmpty
|
||||
if calibrated && wantCal.count != wantChannels.count {
|
||||
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
|
||||
}
|
||||
|
||||
let meters = wantChannels.enumerated().map { (i, ch) in
|
||||
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
||||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||
}
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
|
||||
|
||||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||||
+ "\(wantWeighting.rawValue)-weighted"
|
||||
+ (calibrated ? "" : ", UNCALIBRATED"))
|
||||
|
||||
// Announced once so the reader knows the band plan without hardcoding it.
|
||||
let hello: [String: Any] = [
|
||||
"hello": 1,
|
||||
"device": device.name,
|
||||
"sampleRate": sampleRate,
|
||||
"deviceChannels": channelCount,
|
||||
"channels": wantChannels,
|
||||
"weighting": wantWeighting.rawValue,
|
||||
"calibrated": calibrated,
|
||||
// Centres and the index of the 1 kHz band, so a tile can fold to any
|
||||
// banding without hardcoding the plan.
|
||||
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
|
||||
"bandN0": bandN0,
|
||||
"bandsPerOctave": 48,
|
||||
]
|
||||
let stdoutHandle = FileHandle.standardOutput
|
||||
func emitLine(_ json: String) {
|
||||
do {
|
||||
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
|
||||
} catch { exit(0) } // downstream pipe closed — nothing to say
|
||||
}
|
||||
if let data = try? JSONSerialization.data(withJSONObject: hello),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
emitLine(line)
|
||||
}
|
||||
|
||||
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
|
||||
// the listener can clear Leq and peaks without restarting capture and losing
|
||||
// the filters' settling.
|
||||
let resetQueue = DispatchQueue(label: "spl-capture.reset")
|
||||
var resetRequested = false
|
||||
DispatchQueue.global().async {
|
||||
while let line = readLine(strippingNewline: true) {
|
||||
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
|
||||
resetQueue.sync { resetRequested = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tapQueue = DispatchQueue(label: "spl-capture.tap")
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||
tapQueue.sync {
|
||||
for (i, ch) in wantChannels.enumerated() {
|
||||
let src = floats[ch - 1] // tap data is per-channel, not interleaved
|
||||
meters[i].push(src, frames)
|
||||
if wantBands { spectra[i].push(src, frames) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
|
||||
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
|
||||
repeating: 1.0 / framesPerSecond)
|
||||
timer.setEventHandler {
|
||||
var wantReset = false
|
||||
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
|
||||
|
||||
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
|
||||
tapQueue.sync {
|
||||
if wantReset { meters.forEach { $0.reset() } }
|
||||
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
|
||||
if wantBands {
|
||||
let sets = spectra.enumerated().map { (i, s) -> String in
|
||||
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
|
||||
.map(num1).joined(separator: ",") + "]"
|
||||
}
|
||||
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||||
}
|
||||
}
|
||||
emitLine(line + "}")
|
||||
}
|
||||
timer.resume()
|
||||
|
||||
signal(SIGINT) { _ in exit(0) }
|
||||
signal(SIGTERM) { _ in exit(0) }
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,460 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SPL Meter server entry.
|
||||
*
|
||||
* Owns one capture child (spl-listener.js) and fans its frames out to every
|
||||
* tile over SSE. Browsers never see the audio device, only the numbers.
|
||||
*
|
||||
* GET /stream → SSE, `state` events at the capture rate
|
||||
* GET /state → the latest frame, for an instant first paint
|
||||
* GET /devices → input devices, for the admin page's picker
|
||||
* GET /channels → configured channels, for the tile gear picker
|
||||
* POST /reset → clear Leq and peak hold
|
||||
* GET /measure → what a calibrator is reading right now, so the
|
||||
* admin page can tell the operator what to type
|
||||
*
|
||||
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
|
||||
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
|
||||
* whose numbers round to what the last one said.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener, captureTool } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
/** How long /measure averages for. Long enough to ride out a wobble, short
|
||||
enough that nobody standing at the microphone gets bored. */
|
||||
const MEASURE_MS = 3000;
|
||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const PING_MS = 15000;
|
||||
|
||||
/**
|
||||
* Below this, unweighted, the input is not quiet — it is not there.
|
||||
*
|
||||
* A real input always carries something: a microphone in a silent room still
|
||||
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
|
||||
* a stream of actual zeros reaches the capture tool's -200 floor, and that
|
||||
* means the device handed us nothing at all.
|
||||
*/
|
||||
const SILENCE_DBFS = -150;
|
||||
/** How long that has to hold before saying so, so a gap between buffers or a
|
||||
device changing sample rate mid-stream doesn't cry wolf. */
|
||||
const SILENCE_GRACE_MS = 5000;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||
let state = null;
|
||||
|
||||
/* ── shared tile settings ───────────────────────────────────────────── */
|
||||
|
||||
const CHANNEL_FIELD = {
|
||||
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
|
||||
};
|
||||
|
||||
/**
|
||||
* Averaging is a view choice, not a measurement one: the capture child
|
||||
* computes all four every frame and sends all four, so one dashboard on Fast
|
||||
* and another on Inf are reading the same measurement two ways.
|
||||
*/
|
||||
function averagingField() {
|
||||
return {
|
||||
type: 'select', label: 'Averaging', default: 'fast',
|
||||
options: [
|
||||
{ value: 'fast', label: 'Fast · 125 ms' },
|
||||
{ value: 'slow', label: 'Slow · 1 s' },
|
||||
{ value: 'inf', label: 'Inf · since reset' },
|
||||
{ value: 'none', label: 'None' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Banding folds the 1/48-octave bands on the wire down to something
|
||||
* readable. Coarser is an exact sum of finer, so this costs nothing and
|
||||
* disturbs no other tile.
|
||||
*/
|
||||
const BANDING_FIELD = {
|
||||
type: 'select', label: 'Banding', default: '3',
|
||||
options: [
|
||||
{ value: '3', label: '1/3 octave' },
|
||||
{ value: '6', label: '1/6 octave' },
|
||||
{ value: '12', label: '1/12 octave' },
|
||||
{ value: '24', label: '1/24 octave' },
|
||||
{ value: '48', label: '1/48 octave' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseChannels(raw) {
|
||||
return String(raw || '1')
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
|
||||
}
|
||||
|
||||
function parseCalibration(raw) {
|
||||
if (!String(raw || '').trim()) return [];
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const channels = parseChannels(config.channels);
|
||||
const calibration = parseCalibration(config.calibration);
|
||||
const moduleDir = __dirname;
|
||||
|
||||
state = {
|
||||
channels,
|
||||
calibrated: calibration.length === channels.length && channels.length > 0,
|
||||
frame: null,
|
||||
meta: null,
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
// The capture tool ships prebuilt in the module (bin/); with none for
|
||||
// this platform every reading is off and the health line says so.
|
||||
state.toolMissing = captureTool(moduleDir).missing;
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!config.device) {
|
||||
state.error = 'No audio device chosen yet';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!channels.length) {
|
||||
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (calibration.length && calibration.length !== channels.length) {
|
||||
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
|
||||
+ 'give one per channel, or leave the offsets empty';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
|
||||
state.listener = createSplListener({
|
||||
device: config.device,
|
||||
channels,
|
||||
weighting: config.weighting || 'A',
|
||||
bands: config.bands !== false,
|
||||
calibration,
|
||||
moduleDir,
|
||||
log,
|
||||
onUpdate(update) {
|
||||
if (!state) return; // a frame in flight when the module unmounted
|
||||
if (update.error) {
|
||||
if (update.error !== state.error) log(update.error);
|
||||
state.error = update.error;
|
||||
state.frame = null;
|
||||
broadcast();
|
||||
return;
|
||||
}
|
||||
state.error = '';
|
||||
state.frame = update.frame;
|
||||
state.meta = update.meta;
|
||||
// Digital silence on every channel is a dead input, not a reading.
|
||||
// Saying "ok" through this is what made a stopped Dante device look
|
||||
// like a frozen module for a day and a half.
|
||||
const dead = update.frame.ch.every(
|
||||
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
|
||||
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
|
||||
const mineStreams = state.streams;
|
||||
state.pinger = setInterval(() => {
|
||||
for (const res of mineStreams) {
|
||||
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
|
||||
}
|
||||
}, PING_MS);
|
||||
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const quiet = silentMs();
|
||||
if (quiet > SILENCE_GRACE_MS) {
|
||||
return { status: 'error',
|
||||
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
|
||||
}
|
||||
const n = state.channels.length;
|
||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||
return state.calibrated
|
||||
? { status: 'ok', message: what }
|
||||
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Four tile kinds, each with only the settings that do something to it.
|
||||
*
|
||||
* An entry's own instanceSchema replaces the module's, which is the point:
|
||||
* an RTA has no use for an alarm threshold and a readout has no use for a
|
||||
* banding, and offering either is how a gear popover stops being read.
|
||||
*
|
||||
* Every setting here is a view choice. None of them reach the capture
|
||||
* child — it already sends every averaging and the finest banding, so two
|
||||
* dashboards can disagree about how to draw the same room.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
metric: {
|
||||
type: 'select', label: 'Show', default: 'level',
|
||||
options: [
|
||||
{ value: 'level', label: 'Level' },
|
||||
{ value: 'leq1m', label: 'Leq 1 min' },
|
||||
{ value: 'leq10m', label: 'Leq 10 min' },
|
||||
{ value: 'peakC', label: 'Peak C' },
|
||||
],
|
||||
},
|
||||
caption: { type: 'switch', label: 'Caption', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
averaging: averagingField(),
|
||||
peakHold: { type: 'switch', label: 'Peak hold', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
banding: BANDING_FIELD,
|
||||
smoothing: {
|
||||
type: 'select', label: 'Smoothing', default: 'medium',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
],
|
||||
},
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
banding: BANDING_FIELD,
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, snapshot());
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
try {
|
||||
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
} catch { return; }
|
||||
// Hold this mount's set, not `state`. teardown() nulls `state` and
|
||||
// then ends every stream, which fires these close handlers — a
|
||||
// handler that reached through `state` threw on null and took the
|
||||
// whole server down with it, on any re-init with a browser
|
||||
// connected. Deleting from a set nobody reads any more is harmless.
|
||||
const streams = state.streams;
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
const tool = captureTool(__dirname);
|
||||
if (tool.missing) {
|
||||
log(`device list unavailable — ${tool.missing}`);
|
||||
json(res, 200, { options: [], error: tool.missing });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
options: devices.map((d) => ({
|
||||
value: d.name,
|
||||
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
log(`device list failed: ${err.message}`);
|
||||
json(res, 200, { options: [] }); // the saved value stays selectable
|
||||
}
|
||||
},
|
||||
|
||||
/** Channel list for a tile's gear popover. */
|
||||
'GET /channels': (req, res) => {
|
||||
json(res, 200, {
|
||||
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
|
||||
});
|
||||
},
|
||||
|
||||
'POST /reset': (req, res) => {
|
||||
if (state.listener) state.listener.reset();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* What the microphone is hearing right now, unweighted and
|
||||
* uncalibrated, plus the offset that would make it read 94 dB.
|
||||
*
|
||||
* The module cannot write its own admin config, so this does not save
|
||||
* anything — it hands back a number for the operator to paste into the
|
||||
* Calibration box. Honest, if one step longer than it should be.
|
||||
*/
|
||||
'GET /measure': (req, res) => {
|
||||
if (!state.listener || state.error) {
|
||||
json(res, 409, { error: state.error || 'not measuring' });
|
||||
return;
|
||||
}
|
||||
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|
||||
|| state.channels[0];
|
||||
const samples = [];
|
||||
const started = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
|
||||
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
||||
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
||||
if (Date.now() - started < MEASURE_MS) return;
|
||||
clearInterval(tick);
|
||||
if (samples.length < 5) {
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
error: 'No steady tone on that channel — check the calibrator is on and seated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Mean of the linear powers, not of the decibels: averaging dB
|
||||
// would quietly under-read a signal that wobbles at all.
|
||||
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
|
||||
const measured = 10 * Math.log10(power);
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
measured: Math.round(measured * 100) / 100,
|
||||
reference: CALIBRATOR_DB,
|
||||
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
|
||||
samples: samples.length,
|
||||
});
|
||||
}, 100);
|
||||
req.on('close', () => clearInterval(tick));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How long every channel has been at digital silence, in ms. */
|
||||
function silentMs() {
|
||||
return state && state.silentSince ? Date.now() - state.silentSince : 0;
|
||||
}
|
||||
|
||||
function describe(ms) {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 90) return `${s} s`;
|
||||
const m = Math.round(s / 60);
|
||||
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
|
||||
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
|
||||
// changed every frame would make the fan-out's change check useless.
|
||||
const quiet = silentMs();
|
||||
return {
|
||||
error: state.error,
|
||||
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
bandCentres: state.meta ? state.meta.bands : [],
|
||||
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||||
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||||
frame: state.frame,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (!state) return; // a listener callback landing after teardown
|
||||
const payload = snapshot();
|
||||
const line = JSON.stringify(payload);
|
||||
if (line === state.lastSent) return; // nothing moved since last frame
|
||||
state.lastSent = line;
|
||||
for (const res of state.streams) {
|
||||
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (!state) return;
|
||||
if (state.listener) state.listener.stop();
|
||||
clearInterval(state.pinger);
|
||||
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
|
||||
state.streams.clear();
|
||||
state = null;
|
||||
}
|
||||
|
||||
function listDevices() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { execFile } = require('child_process');
|
||||
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
|
||||
(err, stdout) => {
|
||||
if (err && !stdout) { reject(err); return; }
|
||||
try { resolve(JSON.parse(stdout).devices || []); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Sound level listener — owns the native capture child.
|
||||
*
|
||||
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
|
||||
* source in native/) for the
|
||||
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||
* latest frame upward. All measurement happens in the child; this file is
|
||||
* plumbing and supervision, nothing more.
|
||||
*
|
||||
* Reports via onUpdate:
|
||||
*
|
||||
* { frame: {...}, meta: {...} } measuring
|
||||
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||
*
|
||||
* The capture tool ships prebuilt inside the module, one binary per
|
||||
* platform under bin/ (module guide, "Native helpers"), exactly like the
|
||||
* Timers module's ltc-capture: nothing is ever built on the machine running
|
||||
* ProdDash. captureTool() picks the binary for this platform; when there is
|
||||
* none, measurement reports itself off and the rest of the module carries on.
|
||||
*
|
||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||
* bare launchd job gets SILENT ZEROS from every input, with no error
|
||||
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
|
||||
* logged-in GUI context and approve the microphone prompt once.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||
|
||||
/** What the module ships in bin/, by platform and architecture. */
|
||||
const HELPERS = {
|
||||
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
|
||||
win32: { x64: 'spl-capture-win-x64.exe' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The capture tool for this machine. It is never built here and never
|
||||
* looked for anywhere else: the module ships it, or measurement is off on
|
||||
* this platform and says so. `bin` is the usable binary; otherwise
|
||||
* `missing` is what to tell the admin page and the tiles.
|
||||
*
|
||||
* @returns {{bin:string, missing:string}}
|
||||
*/
|
||||
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||
const name = HELPERS[platform]?.[arch] || '';
|
||||
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
|
||||
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||
return {
|
||||
bin: '',
|
||||
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
|
||||
};
|
||||
}
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
const BAD_CONFIG_RETRY_MS = 30000;
|
||||
/** No frame for this long means the child is alive but not delivering. */
|
||||
const STALL_MS = 3000;
|
||||
|
||||
/**
|
||||
* @param {{device:string, channels:number[], weighting:string,
|
||||
* bands:boolean, calibration:number[], moduleDir:string,
|
||||
* log:Function, onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void, reset:()=>void}}
|
||||
*/
|
||||
function createSplListener(opts) {
|
||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
|
||||
let child = null;
|
||||
let stopped = false;
|
||||
let restartTimer = null;
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
if (stopped || restartTimer) return;
|
||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||
}
|
||||
|
||||
function armStallWatch() {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
if (stopped || !child) return;
|
||||
if (Date.now() - lastFrameWall >= STALL_MS) {
|
||||
log('no frames for 3 s — restarting capture');
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
}
|
||||
}, STALL_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped) return;
|
||||
|
||||
const tool = captureTool(moduleDir);
|
||||
if (tool.missing) {
|
||||
// No binary for this platform: say so and look again later — a module
|
||||
// update remounts us anyway, this is only belt and braces.
|
||||
onUpdate({ error: tool.missing });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// No averaging or banding flag: the child computes every averaging and
|
||||
// the finest banding regardless, so tiles can differ without a restart.
|
||||
const args = ['--device', device, '--channels', channels.join(','),
|
||||
'--weighting', weighting, '--rate', '20'];
|
||||
if (bands) args.push('--bands');
|
||||
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
|
||||
|
||||
startedAt = Date.now();
|
||||
meta = null;
|
||||
let stderrTail = '';
|
||||
let buf = '';
|
||||
|
||||
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (text) => {
|
||||
stderrTail = (stderrTail + text).slice(-500);
|
||||
});
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (text) => {
|
||||
buf += text;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (!line) continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.hello) {
|
||||
meta = obj;
|
||||
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
|
||||
+ `${obj.weighting}-weighted`
|
||||
+ (obj.calibrated ? '' : ' (uncalibrated)'));
|
||||
continue;
|
||||
}
|
||||
if (!obj.ch) continue;
|
||||
lastFrameWall = Date.now();
|
||||
armStallWatch();
|
||||
onUpdate({ frame: obj, meta });
|
||||
}
|
||||
// A run of junk with no newline would grow forever; the child never
|
||||
// emits a line this long, so anything that does is a bug upstream.
|
||||
if (buf.length > 65536) buf = '';
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
|
||||
child = null;
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child = null;
|
||||
clearTimeout(stallTimer);
|
||||
if (stopped) return;
|
||||
const ranFor = Date.now() - startedAt;
|
||||
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
|
||||
// Exiting almost immediately means it never got as far as capturing —
|
||||
// an unknown device, an out-of-range channel, a denied microphone.
|
||||
// Those do not fix themselves, so back off hard and say why.
|
||||
if (ranFor < 2000) {
|
||||
onUpdate({ error: why });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
} else {
|
||||
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
|
||||
scheduleRestart(RESTART_MS);
|
||||
}
|
||||
});
|
||||
|
||||
armStallWatch();
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
return {
|
||||
/** Clear Leq and peak hold without restarting capture — the filters keep
|
||||
their settling, so the live number never dips. */
|
||||
reset() {
|
||||
if (child && child.stdin.writable) {
|
||||
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(restartTimer); restartTimer = null;
|
||||
clearTimeout(stallTimer); stallTimer = null;
|
||||
if (child) {
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
child = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener, captureTool };
|
||||
@@ -0,0 +1,154 @@
|
||||
/* SPL Meter — every colour comes from the shell's variables so themes and
|
||||
per-tile tints reach in. Nothing here is hardcoded except the canvas
|
||||
heat ramp, which client.js owns because it has to interpolate. */
|
||||
|
||||
[data-module="spl-meter"] .spl {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── readout ──────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-readout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25em;
|
||||
min-height: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2em;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* A first-paint size only. client.js measures the text and sets the real
|
||||
one inline, because CSS has to assume a digit count and gets it wrong the
|
||||
moment the reading needs a sixth character: the coefficient that fills a
|
||||
tile for "-45.9" pushes "-100.5" straight out through the side. */
|
||||
[data-module="spl-meter"] .spl-num {
|
||||
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-unit {
|
||||
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||
|
||||
/* A dead input reads danger, not muted: silence that is a fault should not
|
||||
look like silence that is a quiet room. */
|
||||
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
|
||||
|
||||
/* ── level bars ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4em 1fr 3.4em;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-track {
|
||||
position: relative;
|
||||
height: 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--border) 60%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
|
||||
|
||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||
visible when the bar falls away underneath it. */
|
||||
[data-module="spl-meter"] .spl-bar-peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--text);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
|
||||
|
||||
/* ── canvas tiles ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* The readout scales off both of the tile's dimensions, so the container
|
||||
has to be sized on both axes — inline-size alone leaves cqh undefined. */
|
||||
[data-module="spl-meter"] .spl { container-type: size; }
|
||||
|
||||
/* The sub-line is the first thing to go when the tile gets short: the
|
||||
number is the point, and a clipped caption under it is worse than none. */
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container (max-height: 90px) {
|
||||
[data-module="spl-meter"] .spl-sub { display: none; }
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.2",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
|
||||
| `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
|
||||
| `port` | Port this listener serves on (default `24481`) |
|
||||
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"prodcomUrl": "http://10.3.11.152:24480",
|
||||
"prodcomUrl": "",
|
||||
"port": 24481,
|
||||
"apiKey": ""
|
||||
}
|
||||
|
||||
@@ -23,10 +23,15 @@ try {
|
||||
console.warn('config.json not found or invalid, using defaults/env vars');
|
||||
}
|
||||
|
||||
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
|
||||
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
|
||||
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
||||
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
||||
|
||||
if (!PRODCOM_URL) {
|
||||
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
|
||||
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
|
||||
process.exit(1);
|
||||
}
|
||||
const target = new URL(PRODCOM_URL);
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
|
||||
|
||||
+275
-6
@@ -94,6 +94,35 @@ body { overflow: auto; }
|
||||
}
|
||||
.module-health.error { color: var(--danger); }
|
||||
|
||||
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
|
||||
"no ProPresenter host configured"): a readout, so it sits in a bordered
|
||||
monospace box a shade lighter than the card; ok / connecting / error
|
||||
show as the text (and border) colour, the box itself never changes */
|
||||
.module-status {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.module-status:empty { display: none; }
|
||||
.module-status.ok { color: var(--text); }
|
||||
.module-status.connecting {
|
||||
color: var(--warn);
|
||||
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||
}
|
||||
.module-status.error {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||
}
|
||||
|
||||
.module-mount-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
@@ -106,11 +135,33 @@ body { overflow: auto; }
|
||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.module-form .field { max-width: 420px; }
|
||||
|
||||
/* a multi-line string (manifest type `text`): the same box as the other
|
||||
inputs (those rules live in the shell's style.css), a few lines tall */
|
||||
.field textarea {
|
||||
padding: 7px 9px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
}
|
||||
.field textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* 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; }
|
||||
/* several picks from a list (manifest type `multiselect`) */
|
||||
.field.multiselect-field { cursor: default; }
|
||||
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
|
||||
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||
|
||||
/* labeled config subsections (manifest field `group`) */
|
||||
@@ -121,6 +172,10 @@ body { overflow: auto; }
|
||||
border-radius: 9px;
|
||||
}
|
||||
.field-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
position: relative; /* the ⓘ popover hangs off this row */
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -128,8 +183,82 @@ body { overflow: auto; }
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.field-group-name { min-width: 0; }
|
||||
.field-group .field:last-child { margin-bottom: 0; }
|
||||
|
||||
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
|
||||
focus peeks at the text; a click pins it open until the next click. */
|
||||
.group-info { display: inline-flex; align-items: center; }
|
||||
.group-info-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: help;
|
||||
}
|
||||
.group-info-btn:hover,
|
||||
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
|
||||
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.group-info-pop {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 5;
|
||||
max-width: min(440px, 100%);
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--text) 6%, var(--panel));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
cursor: auto;
|
||||
}
|
||||
/* keeps the popover open while the pointer crosses the gap down to it */
|
||||
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
|
||||
.group-info:hover .group-info-pop,
|
||||
.group-info-btn:focus-visible + .group-info-pop,
|
||||
.group-info.is-open .group-info-pop { display: block; }
|
||||
|
||||
/* group toggle (configGroups[name].toggle): the named switch sits at the
|
||||
right of the title row as the group's on/off; the body dims while it is
|
||||
off but stays editable */
|
||||
.field-group-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
.field-group-toggle .field {
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
|
||||
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
|
||||
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
|
||||
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
|
||||
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
|
||||
|
||||
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||
details.field-group > summary.field-group-title {
|
||||
cursor: pointer;
|
||||
@@ -160,18 +289,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
|
||||
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,
|
||||
/* one line of guidance under a control (field `help`) */
|
||||
.field-help {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.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
|
||||
@@ -190,12 +317,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
|
||||
}
|
||||
.field.switch-field .switch-label { font-size: 13px; }
|
||||
|
||||
/* the card's one action row: status on the left, Configure / Apply on the right */
|
||||
.module-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end; /* the button stays bottom-right when a readout wraps */
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.module-name.is-toggle { cursor: pointer; }
|
||||
.module-name.is-toggle:hover,
|
||||
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
|
||||
.module-actions .admin-status { flex: 1; margin-top: 0; }
|
||||
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
|
||||
.module-actions .btn { flex: none; margin-left: auto; }
|
||||
|
||||
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
.admin-status.error { color: var(--danger); }
|
||||
@@ -309,6 +443,141 @@ a.btn { text-decoration: none; }
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catalog-main { flex: 1; min-width: 0; }
|
||||
.catalog-pick { margin: 5px 10px 0 0; flex: none; }
|
||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||
.catalog-row .module-health { margin-top: 2px; }
|
||||
|
||||
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||
accent as a status dot) and the name; the current one wears the accent */
|
||||
/* ── a collapsible admin section ─────────────────────────────────────
|
||||
The pattern for any section of this page that folds. The markup is a
|
||||
<details class="admin-fold"> holding the section's contents, headed by a
|
||||
<summary class="admin-h1 admin-fold-title">: the title keeps the same
|
||||
type as every other section heading — a <summary> would otherwise come
|
||||
out at the normal weight — and the caret follows the words, so a folded
|
||||
section still reads as a heading first. The whole row is the hit target. */
|
||||
details.admin-fold > summary.admin-fold-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
|
||||
user-select: none;
|
||||
}
|
||||
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
|
||||
/* after the title, not before it — the heading reads first, the caret says
|
||||
there is more behind it */
|
||||
details.admin-fold > summary.admin-fold-title::after {
|
||||
content: "";
|
||||
width: 0; height: 0;
|
||||
border-left: 6px solid var(--muted);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
|
||||
.theme-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.theme-swatch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-swatch:hover { border-color: var(--muted); }
|
||||
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.theme-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 46px;
|
||||
border-radius: 6px;
|
||||
background: var(--sw-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
|
||||
overflow: hidden;
|
||||
}
|
||||
.theme-preview::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px; right: 8px; top: 10px; bottom: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--sw-panel);
|
||||
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
|
||||
}
|
||||
.theme-preview::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 14px; top: 16px;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--sw-accent);
|
||||
box-shadow: 0 0 6px var(--sw-accent);
|
||||
}
|
||||
.theme-name { display: flex; align-items: center; gap: 6px; }
|
||||
.theme-swatch.is-current .theme-name::after {
|
||||
content: "current";
|
||||
margin-left: auto;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
#theme-status { margin-top: 4px; }
|
||||
|
||||
/* ── the admin PIN ── */
|
||||
|
||||
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
|
||||
.pin-input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.35em;
|
||||
text-align: center;
|
||||
max-width: 9em;
|
||||
}
|
||||
.pin-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.pin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pin-field[hidden] { display: none; }
|
||||
.pin-field input {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.pin-field input:focus { outline: none; border-color: var(--accent); }
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
+512
-23
@@ -2,8 +2,10 @@
|
||||
|
||||
Everything here is server-wide: module enable/disable and admin config
|
||||
(written to config/modules.json, module re-inited on save), plus named
|
||||
layout management. Guarded by the shared passcode when one is set. */
|
||||
layout management. Behind the admin PIN when one is set — a shell setting of
|
||||
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
|
||||
|
||||
const securitySection = document.getElementById('security-section');
|
||||
const loginCard = document.getElementById('login-card');
|
||||
const loginStatus = document.getElementById('login-status');
|
||||
const passcodeInput = document.getElementById('passcode');
|
||||
@@ -22,6 +24,9 @@ const updateStatus = document.getElementById('update-status');
|
||||
const updateBtn = document.getElementById('update-btn');
|
||||
const updateChanges = document.getElementById('update-changes');
|
||||
const shellVersionEl = document.getElementById('shell-version');
|
||||
const themeSection = document.getElementById('theme-section');
|
||||
const themeSwatches = document.getElementById('theme-swatches');
|
||||
const themeStatus = document.getElementById('theme-status');
|
||||
|
||||
/** The shell version the page loaded with — the update flow watches it change. */
|
||||
let shellVersion = '';
|
||||
@@ -58,20 +63,31 @@ function showLogin() {
|
||||
availableSection.hidden = true;
|
||||
layoutsSection.hidden = true;
|
||||
updateCard.hidden = true;
|
||||
themeSection.hidden = true;
|
||||
securitySection.hidden = true;
|
||||
passcodeInput.value = '';
|
||||
passcodeInput.focus();
|
||||
}
|
||||
|
||||
let loggingIn = false;
|
||||
|
||||
async function login() {
|
||||
if (loggingIn) return;
|
||||
loggingIn = true;
|
||||
loginStatus.textContent = '';
|
||||
loginStatus.className = 'admin-status';
|
||||
try {
|
||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) });
|
||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
|
||||
passcodeInput.value = '';
|
||||
loginCard.hidden = true;
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
passcodeInput.value = '';
|
||||
loginStatus.textContent = e.message;
|
||||
loginStatus.className = 'admin-status error';
|
||||
passcodeInput.focus();
|
||||
} finally {
|
||||
loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +95,82 @@ document.getElementById('login-btn').addEventListener('click', login);
|
||||
passcodeInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') login();
|
||||
});
|
||||
// Six digits is the whole PIN, so there is nothing left to press.
|
||||
passcodeInput.addEventListener('input', () => {
|
||||
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
|
||||
if (passcodeInput.value.length === 6) login();
|
||||
});
|
||||
|
||||
/* ── the admin PIN ───────────────────────────────────────────────────── */
|
||||
|
||||
const pinCurrentField = document.getElementById('pin-current-field');
|
||||
const pinCurrent = document.getElementById('pin-current');
|
||||
const pinNew = document.getElementById('pin-new');
|
||||
const pinRepeat = document.getElementById('pin-repeat');
|
||||
const pinSaveBtn = document.getElementById('pin-save');
|
||||
const pinClearBtn = document.getElementById('pin-clear');
|
||||
const pinStatus = document.getElementById('pin-status');
|
||||
const pinStateEl = document.getElementById('pin-state');
|
||||
|
||||
for (const input of [pinCurrent, pinNew, pinRepeat]) {
|
||||
input.addEventListener('input', () => {
|
||||
input.value = input.value.replace(/\D/g, '').slice(0, 6);
|
||||
});
|
||||
}
|
||||
|
||||
/** Paint the section for "no PIN yet" or "one is set". */
|
||||
function renderPin(state) {
|
||||
const set = Boolean(state.pinSet);
|
||||
const fromEnv = Boolean(state.pinFromEnv);
|
||||
securitySection.hidden = false;
|
||||
pinCurrentField.hidden = !set;
|
||||
pinClearBtn.hidden = !set;
|
||||
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
|
||||
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
|
||||
pinStateEl.textContent = fromEnv
|
||||
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
|
||||
: set
|
||||
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
|
||||
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
|
||||
}
|
||||
|
||||
function pinMessage(text, bad = false) {
|
||||
pinStatus.textContent = text;
|
||||
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
|
||||
}
|
||||
|
||||
async function savePin(clearing = false) {
|
||||
pinStatus.textContent = '';
|
||||
pinStatus.className = 'admin-status';
|
||||
const current = pinCurrent.value;
|
||||
const next = clearing ? '' : pinNew.value;
|
||||
if (!clearing) {
|
||||
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
|
||||
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
|
||||
}
|
||||
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
|
||||
return pinMessage('Enter the current PIN to change it.', true);
|
||||
}
|
||||
pinSaveBtn.disabled = true;
|
||||
pinClearBtn.disabled = true;
|
||||
try {
|
||||
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
|
||||
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
|
||||
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
pinMessage(e.message, true);
|
||||
} finally {
|
||||
pinSaveBtn.disabled = false;
|
||||
pinClearBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
pinSaveBtn.addEventListener('click', () => savePin(false));
|
||||
pinClearBtn.addEventListener('click', () => {
|
||||
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
|
||||
});
|
||||
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
|
||||
|
||||
/* ── module cards ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -224,6 +316,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
field.append(span, select);
|
||||
read = () => select.value;
|
||||
selectEl = select;
|
||||
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||
// Several picks from one list — a switch per option, stored as an array
|
||||
// of values. A saved value the list no longer offers stays checked and
|
||||
// marked, so a channel the account has left isn't dropped in silence.
|
||||
field.classList.add('multiselect-field');
|
||||
// The wrapper is a <label>; with several controls inside, a click on the
|
||||
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
|
||||
field.htmlFor = '';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multiselect';
|
||||
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
|
||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||
const fill = (options) => {
|
||||
for (const input of list.querySelectorAll('input')) {
|
||||
if (input.checked) chosen.add(input.value);
|
||||
else chosen.delete(input.value);
|
||||
}
|
||||
list.innerHTML = '';
|
||||
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||
for (const opt of all) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'multiselect-row';
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.value = optValue(opt);
|
||||
input.checked = chosen.has(input.value);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'track';
|
||||
toggle.append(input, track);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'switch-label';
|
||||
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
row.append(toggle, text);
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target === input) return; // the box toggles itself
|
||||
input.checked = !input.checked;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
list.appendChild(row);
|
||||
}
|
||||
if (!all.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'multiselect-empty';
|
||||
none.textContent = 'Nothing to choose from yet.';
|
||||
list.appendChild(none);
|
||||
}
|
||||
};
|
||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||
if (spec.optionsRoute && moduleId) {
|
||||
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();
|
||||
}
|
||||
field.append(span, list);
|
||||
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
} else if (type === 'color') {
|
||||
// A swatch picker; the value is always a #rrggbb string.
|
||||
field.classList.add('color-field');
|
||||
@@ -235,6 +394,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
||||
field.append(span, input);
|
||||
read = () => input.value;
|
||||
} else if (type === 'text') {
|
||||
// A multi-line string — one item per line by convention (a list of
|
||||
// presets, say). Stored as-is, newlines included.
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.rows = 5;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = value === undefined || value === null ? '' : String(value);
|
||||
field.append(span, textarea);
|
||||
read = () => textarea.value;
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
@@ -277,6 +447,99 @@ function groupHasValues(fields, mod) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
|
||||
|
||||
let groupInfoSeq = 0;
|
||||
|
||||
function closeGroupInfos(except) {
|
||||
for (const open of document.querySelectorAll('.group-info.is-open')) {
|
||||
if (open === except) continue;
|
||||
open.classList.remove('is-open');
|
||||
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* configGroups[name].help as an ⓘ beside the group title: click toggles a
|
||||
* small popover with the text (hover and keyboard focus show it too), so a
|
||||
* group can carry a paragraph without pushing its fields down the page. A
|
||||
* real button, labelled, so it works from the keyboard; inside a <summary>
|
||||
* it must never fold the group.
|
||||
*/
|
||||
function groupInfo(groupName, text) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'group-info';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'group-info-btn';
|
||||
btn.textContent = 'i';
|
||||
btn.setAttribute('aria-label', `About ${groupName}`);
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
const pop = document.createElement('span');
|
||||
pop.className = 'group-info-pop';
|
||||
pop.id = `group-info-${++groupInfoSeq}`;
|
||||
pop.setAttribute('role', 'note');
|
||||
pop.textContent = text;
|
||||
btn.setAttribute('aria-describedby', pop.id);
|
||||
wrap.append(btn, pop);
|
||||
const setOpen = (open) => {
|
||||
wrap.classList.toggle('is-open', open);
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault(); // a click in a <summary> would otherwise fold the group
|
||||
e.stopPropagation();
|
||||
const open = !wrap.classList.contains('is-open');
|
||||
closeGroupInfos(wrap);
|
||||
setOpen(open);
|
||||
});
|
||||
pop.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
wrap.addEventListener('keydown', (e) => {
|
||||
// Enter / Space activate the button (a click); they must not reach a
|
||||
// <summary> above as well. Escape closes and hands focus back.
|
||||
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
btn.focus();
|
||||
}
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// A click anywhere else, or Escape, closes an open popover.
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeGroupInfos();
|
||||
});
|
||||
|
||||
/**
|
||||
* configGroups[name].toggle: the named switch/boolean field is the group's
|
||||
* on/off. It moves into the title row, right-aligned, and the body dims
|
||||
* while it is off (fields stay editable). The field itself is untouched —
|
||||
* it is still read on save and still drives showWhen dependents.
|
||||
*/
|
||||
function mountGroupToggle(group, field, control) {
|
||||
const slot = document.createElement('span');
|
||||
slot.className = 'field-group-toggle';
|
||||
slot.appendChild(field);
|
||||
// Inside a <summary>, flipping the switch must not fold the group.
|
||||
slot.addEventListener('click', (e) => e.stopPropagation());
|
||||
slot.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||
});
|
||||
group.title.appendChild(slot);
|
||||
group.el.classList.add('has-toggle');
|
||||
const sync = () => group.el.classList.toggle('is-off', !control.checked);
|
||||
control.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
function buildModuleCard(mod) {
|
||||
const card = document.createElement('section');
|
||||
card.className = 'admin-card';
|
||||
@@ -351,8 +614,11 @@ function buildModuleCard(mod) {
|
||||
desc.textContent = mod.description;
|
||||
card.appendChild(desc);
|
||||
|
||||
// The module's own words about its upstream ("polling timers on
|
||||
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
|
||||
// and the state (ok / connecting / error) is its text colour.
|
||||
const healthEl = document.createElement('div');
|
||||
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||
healthEl.className = 'module-status ' + healthClass(mod);
|
||||
healthEl.textContent = healthText(mod);
|
||||
card.appendChild(healthEl);
|
||||
|
||||
@@ -395,7 +661,9 @@ function buildModuleCard(mod) {
|
||||
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.
|
||||
// `configGroups[name]` adds { collapsed, columns, help, toggle } for
|
||||
// that group: help is an ⓘ beside the title, toggle names the switch
|
||||
// that is the group's on/off and lives in the title row.
|
||||
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||
let groupName = null;
|
||||
let container = form;
|
||||
@@ -412,7 +680,11 @@ function buildModuleCard(mod) {
|
||||
group.className = 'field-group';
|
||||
const title = document.createElement(folds ? 'summary' : 'div');
|
||||
title.className = 'field-group-title';
|
||||
title.textContent = g;
|
||||
const titleName = document.createElement('span');
|
||||
titleName.className = 'field-group-name';
|
||||
titleName.textContent = g;
|
||||
title.appendChild(titleName);
|
||||
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
|
||||
group.appendChild(title);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'field-group-body';
|
||||
@@ -421,16 +693,16 @@ function buildModuleCard(mod) {
|
||||
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: [] };
|
||||
currentGroup = {
|
||||
el: group,
|
||||
title,
|
||||
meta,
|
||||
fields: [],
|
||||
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
|
||||
};
|
||||
if (folds) collapsibles.push(currentGroup);
|
||||
} else {
|
||||
container = form;
|
||||
@@ -444,7 +716,13 @@ function buildModuleCard(mod) {
|
||||
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);
|
||||
if (currentGroup && control && key === currentGroup.toggleKey) {
|
||||
// The group's on/off: title row, not the body (never both). A
|
||||
// toggle naming a non-boolean field is ignored and renders normally.
|
||||
mountGroupToggle(currentGroup, field, control);
|
||||
} else {
|
||||
container.appendChild(field);
|
||||
}
|
||||
}
|
||||
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||
for (const dep of dependents) {
|
||||
@@ -473,26 +751,52 @@ function buildModuleCard(mod) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'module-actions';
|
||||
const save = document.createElement('button');
|
||||
save.className = 'btn primary';
|
||||
save.textContent = 'Save & re-init';
|
||||
const status = document.createElement('span');
|
||||
status.className = 'admin-status';
|
||||
actions.append(save, status);
|
||||
form.appendChild(actions);
|
||||
actions.append(status, save);
|
||||
// A card is a status line until someone wants to change something: the
|
||||
// form starts folded, and the row at the bottom holds one button that
|
||||
// does both jobs — "Configure" opens the form and becomes "Apply", which
|
||||
// saves without closing it. The name is the other way in and out.
|
||||
card.appendChild(form);
|
||||
card.appendChild(actions);
|
||||
const openKey = 'proddash:admin:open:' + mod.id;
|
||||
const setOpen = (open) => {
|
||||
form.hidden = !open;
|
||||
status.hidden = !open;
|
||||
save.textContent = open ? 'Apply' : 'Configure';
|
||||
save.className = open ? 'btn primary' : 'btn';
|
||||
// Folded, the readout shares the bottom row with Configure instead of
|
||||
// taking a line of its own; open, it goes back above the form.
|
||||
if (open) card.insertBefore(healthEl, mountError);
|
||||
else actions.insertBefore(healthEl, status);
|
||||
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
|
||||
};
|
||||
let wasOpen = false;
|
||||
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
|
||||
setOpen(wasOpen);
|
||||
name.classList.add('is-toggle');
|
||||
name.title = 'Show or hide this module\'s settings';
|
||||
name.tabIndex = 0;
|
||||
name.setAttribute('role', 'button');
|
||||
name.addEventListener('click', () => setOpen(form.hidden));
|
||||
name.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
|
||||
});
|
||||
|
||||
save.addEventListener('click', async () => {
|
||||
if (form.hidden) return setOpen(true);
|
||||
const config = {};
|
||||
for (const [key, read] of readers) config[key] = read();
|
||||
save.disabled = true;
|
||||
status.textContent = 'Saving…';
|
||||
status.textContent = 'Applying…';
|
||||
status.className = 'admin-status';
|
||||
try {
|
||||
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
||||
status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
|
||||
status.className = 'admin-status ok';
|
||||
refreshStatus();
|
||||
// What the module can list may have changed (credentials just
|
||||
@@ -531,7 +835,7 @@ async function refreshStatus() {
|
||||
if (!els) continue;
|
||||
els.dot.className = 'dot ' + healthClass(mod);
|
||||
els.healthEl.textContent = healthText(mod);
|
||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
||||
els.healthEl.className = 'module-status ' + healthClass(mod);
|
||||
if (!mod.incompatible) {
|
||||
els.mountError.hidden = !mod.mountError;
|
||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||
@@ -543,7 +847,7 @@ async function refreshStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── named layouts ──────────────────────────────────────────────────── */
|
||||
/* ── saved layouts ──────────────────────────────────────────────────── */
|
||||
|
||||
async function loadLayouts() {
|
||||
let body;
|
||||
@@ -559,7 +863,7 @@ async function loadLayouts() {
|
||||
if (!layouts.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.';
|
||||
none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
|
||||
layoutsList.appendChild(none);
|
||||
return;
|
||||
}
|
||||
@@ -605,7 +909,25 @@ async function loadLayouts() {
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, rename, del);
|
||||
// The copy the last save replaced, one back. Restoring swaps the two,
|
||||
// so a restore is itself one restore away from being undone.
|
||||
const restore = document.createElement('button');
|
||||
restore.className = 'btn';
|
||||
restore.textContent = 'Restore previous';
|
||||
restore.title = 'Put back the copy the last save replaced';
|
||||
restore.hidden = !layout.hasPrevious;
|
||||
restore.addEventListener('click', async () => {
|
||||
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
|
||||
try {
|
||||
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
|
||||
toast('Previous version restored');
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, restore, rename, del);
|
||||
layoutsList.appendChild(row);
|
||||
}
|
||||
}
|
||||
@@ -640,10 +962,141 @@ async function loadState() {
|
||||
for (const mod of state.modules || []) {
|
||||
modulesList.appendChild(buildModuleCard(mod));
|
||||
}
|
||||
if (!(state.modules || []).length) {
|
||||
// A packaged ProdDash starts like this: the catalog below is the way in.
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
none.textContent = 'Nothing installed yet — pick from Available modules below.';
|
||||
modulesList.appendChild(none);
|
||||
}
|
||||
renderPin(state);
|
||||
loadTheme();
|
||||
loadLayouts();
|
||||
loadCatalog(false);
|
||||
}
|
||||
|
||||
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The dashboard palette is a server-wide choice. The server lists the
|
||||
presets (GET /api/theme — public, dashboards need it too) and the admin
|
||||
page shows them as swatches; picking one POSTs it, applies it here at
|
||||
once, and the server's `theme` shell event switches every open dashboard
|
||||
— and any other admin page — live. The palettes themselves are
|
||||
html[data-theme="…"] variable sets in the shell's style.css, which this
|
||||
page shares, so the admin page follows its own choice. */
|
||||
|
||||
const LS_THEME = 'proddash:theme';
|
||||
let themeList = [];
|
||||
let currentTheme = '';
|
||||
|
||||
function applyTheme(id) {
|
||||
const theme = String(id || '');
|
||||
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
|
||||
}
|
||||
|
||||
// The last-known theme first (shared with the dashboard, same origin), so
|
||||
// the page doesn't flash the default palette before the server answers.
|
||||
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
|
||||
|
||||
function themeName(id) {
|
||||
return themeList.find((t) => t.id === id)?.name || id;
|
||||
}
|
||||
|
||||
function renderThemes() {
|
||||
themeSwatches.innerHTML = '';
|
||||
for (const t of themeList) {
|
||||
const current = t.id === currentTheme;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.setAttribute('aria-checked', current ? 'true' : 'false');
|
||||
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'theme-preview';
|
||||
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
|
||||
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
|
||||
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
|
||||
const name = document.createElement('span');
|
||||
name.className = 'theme-name';
|
||||
name.textContent = t.name;
|
||||
btn.append(preview, name);
|
||||
btn.addEventListener('click', () => chooseTheme(t.id));
|
||||
themeSwatches.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the server's theme and list. Applied to this page either way (the
|
||||
* endpoint is public, so even the login card wears the right palette); the
|
||||
* swatch section itself is only revealed for an unlocked page.
|
||||
*/
|
||||
async function loadTheme(reveal = true) {
|
||||
let body;
|
||||
try {
|
||||
body = await api('/api/theme');
|
||||
} catch {
|
||||
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
|
||||
return;
|
||||
}
|
||||
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
|
||||
currentTheme = String(body.theme || '');
|
||||
applyTheme(currentTheme);
|
||||
renderThemes();
|
||||
if (reveal) themeSection.hidden = !themeList.length;
|
||||
}
|
||||
|
||||
loadTheme(false);
|
||||
|
||||
async function chooseTheme(id) {
|
||||
if (id === currentTheme) return;
|
||||
const previous = currentTheme;
|
||||
currentTheme = id;
|
||||
applyTheme(id); // at once, here — the server's event confirms it everywhere else
|
||||
renderThemes();
|
||||
themeStatus.textContent = '';
|
||||
themeStatus.className = 'admin-status';
|
||||
try {
|
||||
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
|
||||
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
|
||||
themeStatus.className = 'admin-status ok';
|
||||
} catch (e) {
|
||||
currentTheme = previous;
|
||||
applyTheme(previous);
|
||||
renderThemes();
|
||||
themeStatus.textContent = e.message;
|
||||
themeStatus.className = 'admin-status error';
|
||||
if (e.authRequired) showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// Follow a theme change made elsewhere (another admin page) live.
|
||||
let themeEvents = null;
|
||||
let themeEventsRetry = null;
|
||||
|
||||
function subscribeThemeEvents() {
|
||||
try { themeEvents?.close(); } catch { /* not open */ }
|
||||
themeEvents = new EventSource('/api/events');
|
||||
themeEvents.addEventListener('theme', (e) => {
|
||||
let theme = '';
|
||||
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
|
||||
if (!theme || theme === currentTheme) return;
|
||||
currentTheme = theme;
|
||||
applyTheme(theme);
|
||||
renderThemes();
|
||||
});
|
||||
themeEvents.onerror = () => {
|
||||
if (themeEvents.readyState === EventSource.CLOSED) {
|
||||
clearTimeout(themeEventsRetry);
|
||||
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribeThemeEvents();
|
||||
|
||||
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||
|
||||
function fmtTime(ms) {
|
||||
@@ -717,6 +1170,7 @@ function renderAvailable(cat) {
|
||||
els.updateSlot.appendChild(btn);
|
||||
}
|
||||
const available = cat.available || [];
|
||||
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
|
||||
if (!available.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'layouts-empty';
|
||||
@@ -759,11 +1213,46 @@ function renderAvailable(cat) {
|
||||
btn.disabled = !item.compatible;
|
||||
btn.title = item.compatible ? '' : item.reason;
|
||||
btn.addEventListener('click', () => installFromCatalog(item, btn));
|
||||
if (item.compatible) {
|
||||
const pick = document.createElement('input');
|
||||
pick.type = 'checkbox';
|
||||
pick.className = 'catalog-pick';
|
||||
pick.dataset.id = item.id;
|
||||
pick.dataset.name = item.name;
|
||||
pick.title = 'Tick several, then Install selected';
|
||||
row.append(pick);
|
||||
}
|
||||
row.append(main, btn);
|
||||
availableList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
const installSelectedBtn = document.getElementById('install-selected');
|
||||
|
||||
/** The ticked modules, in one request; the answer says which ones landed. */
|
||||
async function installSelected() {
|
||||
const picks = [...availableList.querySelectorAll('.catalog-pick:checked')];
|
||||
if (!picks.length) return toast('Tick the modules to install first', true);
|
||||
installSelectedBtn.disabled = true;
|
||||
installSelectedBtn.textContent = `Installing ${picks.length}…`;
|
||||
try {
|
||||
const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) });
|
||||
const done = (r.results || []).filter((x) => x.ok).length;
|
||||
const failed = (r.results || []).filter((x) => !x.ok);
|
||||
toast(failed.length
|
||||
? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}`
|
||||
: `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0);
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
} finally {
|
||||
installSelectedBtn.disabled = false;
|
||||
installSelectedBtn.textContent = 'Install selected';
|
||||
}
|
||||
}
|
||||
installSelectedBtn.addEventListener('click', installSelected);
|
||||
|
||||
async function installFromCatalog(item, btn) {
|
||||
btn.disabled = true;
|
||||
const label = btn.textContent;
|
||||
|
||||
+42
-11
@@ -18,10 +18,11 @@
|
||||
|
||||
<main id="admin-main">
|
||||
<section class="admin-card" id="login-card" hidden>
|
||||
<h2>Admin passcode</h2>
|
||||
<h2>Admin PIN</h2>
|
||||
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
|
||||
<div class="login-row">
|
||||
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
|
||||
<input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
|
||||
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
|
||||
<button class="btn primary" id="login-btn">Unlock</button>
|
||||
</div>
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
@@ -39,11 +40,16 @@
|
||||
<div class="admin-status" id="update-status"></div>
|
||||
</section>
|
||||
|
||||
<section id="theme-section" hidden>
|
||||
<details class="admin-fold">
|
||||
<summary class="admin-h1 admin-fold-title">Theme</summary>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
<h1 class="admin-h1">Modules</h1>
|
||||
<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)
|
||||
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>
|
||||
@@ -51,10 +57,8 @@
|
||||
|
||||
<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 primary" id="install-selected" hidden>Install selected</button>
|
||||
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||
<span class="admin-status" id="catalog-status"></span>
|
||||
</div>
|
||||
@@ -62,11 +66,38 @@
|
||||
</section>
|
||||
|
||||
<section id="layouts-section" hidden>
|
||||
<h1 class="admin-h1">Named layouts</h1>
|
||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
|
||||
<h1 class="admin-h1">Saved layouts</h1>
|
||||
<div id="layouts-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="security-section" hidden>
|
||||
<h1 class="admin-h1">Admin PIN</h1>
|
||||
<p class="admin-copy" id="pin-state"></p>
|
||||
<div class="admin-card">
|
||||
<div class="pin-form">
|
||||
<label class="pin-field" id="pin-current-field" hidden>
|
||||
<span>Current PIN</span>
|
||||
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="••••••">
|
||||
</label>
|
||||
<label class="pin-field">
|
||||
<span id="pin-new-label">New PIN</span>
|
||||
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||
</label>
|
||||
<label class="pin-field">
|
||||
<span>Repeat</span>
|
||||
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||
</label>
|
||||
<div class="pin-actions">
|
||||
<button class="btn primary" id="pin-save">Set PIN</button>
|
||||
<button class="btn" id="pin-clear" hidden>Turn off</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-status" id="pin-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
|
||||
+17
-11
@@ -15,12 +15,19 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<!-- 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 id="brand">Prod<span class="accent">Dash</span></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">
|
||||
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
|
||||
<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="M9 6l3-3 3 3"/>
|
||||
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
|
||||
<path d="M4 14h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" 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"/>
|
||||
@@ -28,12 +35,14 @@
|
||||
</button>
|
||||
|
||||
<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 tile" aria-label="Add tile">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<div class="menu" id="add-menu" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown" id="layout-dd">
|
||||
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
|
||||
<button class="tb-btn icon" id="layout-btn" title="Layouts" aria-label="Layouts">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="3" width="8" height="8" rx="1.5"/>
|
||||
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
|
||||
@@ -43,15 +52,12 @@
|
||||
<div class="menu" id="layout-menu" hidden></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
Admin
|
||||
<a class="tb-btn icon" id="admin-link" href="/admin/" title="Admin" aria-label="Admin">
|
||||
<svg width="14" height="14" viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>
|
||||
</a>
|
||||
</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>
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||
|
||||
<main id="gridwrap">
|
||||
<div id="grid">
|
||||
|
||||
+1407
-229
File diff suppressed because it is too large
Load Diff
+559
-173
@@ -10,6 +10,7 @@
|
||||
--text: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #2ee59a;
|
||||
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
|
||||
--danger: #f4433c;
|
||||
--warn: #f0b429;
|
||||
/* grid geometry (shell.js reads these) */
|
||||
@@ -54,57 +55,43 @@ body.menu-hidden #topbar {
|
||||
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; }
|
||||
|
||||
/* The bar's show/hide handle: a small accent lip on its bottom edge, the
|
||||
same idea as a tile's notch. It sits at the left — never screen-centre,
|
||||
where a tile's own (centred) tab lands once both bars are hidden — and
|
||||
grows under the pointer so it is easy to hit without being loud at rest.
|
||||
It used to be the ProdDash wordmark on a pill hanging past the bar, which
|
||||
read as a step cut out of the bar, and animating its font-size on the way
|
||||
out was the jerkiest thing on the page in Safari. Everything here moves by
|
||||
transform, opacity and its own fixed size: no text metrics, no reflow. */
|
||||
#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);
|
||||
left: 10px;
|
||||
width: 28px;
|
||||
height: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
background: var(--accent);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
z-index: 60;
|
||||
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
||||
/* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
|
||||
the lip rides the bar's own collapse instead of running a second,
|
||||
competing animation against it. */
|
||||
transform: translateY(var(--topbar-h, 46px));
|
||||
transition: width 0.16s ease, height 0.16s ease,
|
||||
opacity 0.16s ease, border-radius 0.16s 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 {
|
||||
#topbar-notch:hover,
|
||||
#topbar-notch:focus-visible {
|
||||
width: 60px;
|
||||
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);
|
||||
opacity: 1;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
|
||||
edge without a rule of its own. */
|
||||
|
||||
#brand {
|
||||
font-weight: 800;
|
||||
@@ -139,6 +126,14 @@ body.menu-hidden #topbar-notch::after {
|
||||
line-height: 1;
|
||||
}
|
||||
.tb-btn.icon svg { display: block; }
|
||||
/* A top-bar button that is a switch rather than an action lights up the same
|
||||
way an open tile button does, so "on" reads the same everywhere. */
|
||||
.tb-btn.is-on,
|
||||
.tb-btn.is-on:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -183,6 +178,7 @@ body.menu-hidden #topbar-notch::after {
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
.menu-item[disabled] { opacity: 0.45; cursor: default; }
|
||||
.menu-item.danger { color: var(--danger); }
|
||||
.menu-note {
|
||||
padding: 8px 10px;
|
||||
@@ -191,6 +187,36 @@ body.menu-hidden #topbar-notch::after {
|
||||
white-space: normal;
|
||||
}
|
||||
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||
|
||||
/* the Layouts menu's grid-density row: Grid Size − 12 + */
|
||||
.menu-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu-stepper-label { flex: 1; }
|
||||
.menu-stepper-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-stepper-btn:hover { border-color: var(--muted); }
|
||||
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
|
||||
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
|
||||
.menu-stepper-value {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.menu-title {
|
||||
padding: 6px 10px 3px;
|
||||
color: var(--muted);
|
||||
@@ -246,6 +272,18 @@ body.menu-hidden #topbar-notch::after {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
|
||||
a tinted title bar. Module content keeps its own colours — this is the
|
||||
tile's chrome, so a tinted tile still reads like every other one. */
|
||||
.tile.is-tinted {
|
||||
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
|
||||
}
|
||||
.tile.is-tinted > .tile-head {
|
||||
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
|
||||
}
|
||||
|
||||
.tile.dragging,
|
||||
.tile.resizing {
|
||||
z-index: 40;
|
||||
@@ -270,11 +308,33 @@ body.menu-hidden #topbar-notch::after {
|
||||
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||
}
|
||||
.tile.dragging .tile-head { cursor: grabbing; }
|
||||
/* hidden = collapsed, not removed, so the notch toggle animates */
|
||||
.tile.head-hidden .tile-head {
|
||||
height: 0;
|
||||
border-bottom-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
/* Folded away: the bar leaves the flow and parks just above the tile, so the
|
||||
module keeps the whole body and nothing reflows as the bar comes and goes.
|
||||
Pointing at the tile's top edge slides it back in over the content
|
||||
(shell.js adds .head-peek) — and because it is the same element, its
|
||||
buttons click and its width drags the tile just as they always did. */
|
||||
.tile.head-off > .tile-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
backdrop-filter: blur(7px);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.tile.head-off.head-peek > .tile-head {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tile.is-tinted.head-off > .tile-head {
|
||||
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
|
||||
}
|
||||
|
||||
.tile-head .dot {
|
||||
@@ -290,7 +350,7 @@ body.menu-hidden #topbar-notch::after {
|
||||
.tile-head .dot.error { background: var(--danger); }
|
||||
|
||||
.tile-title {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -318,6 +378,53 @@ body.menu-hidden #topbar-notch::after {
|
||||
.tile-btn:hover { background: var(--border); color: var(--text); }
|
||||
.tile-btn.close:hover { color: var(--danger); }
|
||||
.tile-btn.active { color: var(--accent); }
|
||||
.tile-btn.is-open,
|
||||
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
/* The pin: a state, not an action, so it stays quiet until it is on and
|
||||
sits closer to the label than to the buttons at the other end. It fades
|
||||
in with the rest of the tile's chrome so a wall of unpinned tiles is not
|
||||
a wall of pins. */
|
||||
.tile-btn.pin {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.tile-btn.pin svg { width: 12px; height: 12px; }
|
||||
.tile:hover .tile-btn.pin,
|
||||
.tile-btn.pin:focus-visible { opacity: 0.75; }
|
||||
.tile-btn.pin:hover { opacity: 1; }
|
||||
/* Pinned reads at a glance, whether or not anything is being pointed at. */
|
||||
.tile-btn.pin.is-pinned,
|
||||
.tile:hover .tile-btn.pin.is-pinned {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
|
||||
.tile-btn.pin.is-pinned:hover { background: var(--border); }
|
||||
|
||||
/* Keeps the label hugging its pin however wide the tile is. */
|
||||
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
/* the shell's tooltip: shown at once under the button the pointer is on */
|
||||
#tip {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#tip[hidden] { display: none; }
|
||||
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
||||
|
||||
/* controls a module adds to its own title bar (moduleApi.header) */
|
||||
@@ -338,8 +445,9 @@ body.menu-hidden #topbar-notch::after {
|
||||
}
|
||||
.tile-dd { position: relative; display: inline-flex; }
|
||||
|
||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
||||
overflow:hidden can't clip it */
|
||||
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||
.tile-menu-host { display: contents; }
|
||||
.tile-menu {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
@@ -354,39 +462,6 @@ body.menu-hidden #topbar-notch::after {
|
||||
}
|
||||
.tile-menu[hidden] { display: none; }
|
||||
|
||||
/* notch on the title bar's bottom edge: click to hide/show the bar
|
||||
(same idea as the ProdCom Listener's pull-down menubar handle) */
|
||||
.tile-notch {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 46px;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
cursor: pointer;
|
||||
z-index: 6;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, top 0.22s ease;
|
||||
}
|
||||
.tile:hover .tile-notch { opacity: 0.6; }
|
||||
.tile .tile-notch:hover { opacity: 1; }
|
||||
.tile.head-hidden .tile-notch { top: 0; }
|
||||
.tile-notch::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
margin: 4px auto 0;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.tile-notch:hover::after { background: var(--text); }
|
||||
|
||||
.tile-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -395,82 +470,67 @@ body.menu-hidden #topbar-notch::after {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
|
||||
Every edge and both bottom corners resize, so there is nothing to point
|
||||
at — the grips are invisible and the cursor says what each one does, the
|
||||
way a window's own edges do. Together they tile the tile's outer 8px
|
||||
without a gap: the top strip runs corner to corner, the sides run between
|
||||
it and the bottom corners, and the bottom strip fills between those. */
|
||||
|
||||
.tile-resize,
|
||||
.tile-resize-left,
|
||||
.tile-resize-bottom,
|
||||
.tile-resize-top,
|
||||
.tile-resize-right,
|
||||
.tile-resize-side {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* the two bottom corners: both directions at once */
|
||||
.tile-resize,
|
||||
.tile-resize-left {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
touch-action: none;
|
||||
z-index: 5;
|
||||
color: var(--muted);
|
||||
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||
.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: "";
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
.tile-resize::after {
|
||||
right: 4px;
|
||||
border-right: 2px solid currentColor;
|
||||
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 */
|
||||
/* top and bottom: height only */
|
||||
.tile-resize-top,
|
||||
.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;
|
||||
height: 8px;
|
||||
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-resize-top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Above a revealed title bar: the bar slides in under the pointer here,
|
||||
and this strip has to stay the top edge rather than become part of it. */
|
||||
z-index: 8;
|
||||
}
|
||||
.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); }
|
||||
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
|
||||
|
||||
/* left and right: width only, between the top strip and the bottom corners */
|
||||
.tile-resize-right,
|
||||
.tile-resize-side {
|
||||
top: 8px;
|
||||
bottom: 20px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.tile-resize-right { right: 0; }
|
||||
.tile-resize-side { left: 0; }
|
||||
|
||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||
|
||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
|
||||
hovering reveals a small macOS-split-view-style grab pill. Dragging it
|
||||
trades width between the two tiles. */
|
||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam.
|
||||
Dragging it trades width between the two tiles rather than resizing one,
|
||||
so it sits above their own edge grips. Like them it draws nothing at rest
|
||||
— only while it is being dragged, to show which seam is moving. */
|
||||
.tile-divider {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
@@ -494,21 +554,24 @@ body.menu-hidden #topbar-notch::after {
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.tile-divider:hover::after { opacity: 0.7; }
|
||||
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
||||
|
||||
/* Hide the pills while a tile itself is being moved or corner-resized —
|
||||
they're rebuilt for the new geometry on release. */
|
||||
/* Drop the seams while a tile itself is being moved or resized — they're
|
||||
rebuilt for the new geometry on release. */
|
||||
#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. */
|
||||
editing affordances (the rim grips and the seams) are parked. */
|
||||
body.fullscreen .tile-resize,
|
||||
body.fullscreen .tile-resize-left,
|
||||
body.fullscreen .tile-resize-bottom,
|
||||
body.fullscreen .tile-resize-top,
|
||||
body.fullscreen .tile-resize-right,
|
||||
body.fullscreen .tile-resize-side,
|
||||
body.fullscreen .tile-divider,
|
||||
body.fullscreen #autohide-btn,
|
||||
body.fullscreen #admin-link { display: none; }
|
||||
body.fullscreen #grid { padding-bottom: 0; }
|
||||
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||
@@ -531,28 +594,122 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
|
||||
/* ── per-tile settings popover ─────────────────────────────────────── */
|
||||
|
||||
/* A small window beside its tile (shell.js places and clamps it), not a
|
||||
box inside the tile: a tile can be smaller than its own settings, and the
|
||||
whole point of live settings is watching the tile change. */
|
||||
.tile-settings {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
right: 6px;
|
||||
z-index: 45;
|
||||
min-width: 220px;
|
||||
max-width: calc(100% - 12px);
|
||||
max-height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* as wide as its content wants and no wider: a couple of switches make a
|
||||
small window, a three-column checklist a wide one */
|
||||
width: max-content;
|
||||
min-width: 150px;
|
||||
max-width: min(560px, calc(100vw - 16px));
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
/* the grab bar: a slim accent strip with a grip mark, nothing to read or
|
||||
press — the gear that opened the window wears the same colour meanwhile */
|
||||
.tile-settings-bar {
|
||||
flex: none;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.tile-settings-bar::before {
|
||||
content: "";
|
||||
width: 34px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent-text);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tile-settings-bar:hover::before { opacity: 1; }
|
||||
.tile-settings .field[hidden] { display: none; }
|
||||
/* the tint, the shell's own setting, under a hairline at the foot */
|
||||
.tile-settings .tint-field {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
.tile-settings .tint-clear {
|
||||
flex: none;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
|
||||
.tile-settings .tint-clear[hidden] { display: none; }
|
||||
.tile-settings-bar:active { cursor: grabbing; }
|
||||
.tile-settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
/* Help lines never widen the window: they take no room of their own when the
|
||||
content is measured, then wrap to whatever width the fields settled on. */
|
||||
.tile-settings .field-help,
|
||||
.tile-settings .field-group-help,
|
||||
.tile-settings .multiselect-empty { width: 0; min-width: 100%; }
|
||||
/* a group's on/off sits in its title row; the body dims while it is off */
|
||||
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
|
||||
.tile-settings .field-group-name { flex: 1; min-width: 0; }
|
||||
.tile-settings .group-toggle { margin: 0 0 0 auto; }
|
||||
.tile-settings .group-toggle .check-label { display: none; }
|
||||
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
|
||||
/* several picks from a list (type `multiselect`) */
|
||||
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
|
||||
.tile-settings .multiselect.is-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||
gap: 3px 14px;
|
||||
}
|
||||
.tile-settings .multiselect-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
|
||||
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||
/* a text built from offered variables (type `template`) */
|
||||
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
|
||||
.tile-settings .template-var {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: grab;
|
||||
}
|
||||
.tile-settings .template-var:hover { border-color: var(--accent); }
|
||||
.tile-settings .template-var:active { cursor: grabbing; }
|
||||
|
||||
/* 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;
|
||||
@@ -588,19 +745,20 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
.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; }
|
||||
/* Settings apply as they change, so nothing sits under the last field — its
|
||||
bottom margin would only pad the popover's own padding a second time. */
|
||||
.tile-settings-body > :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;
|
||||
gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
|
||||
}
|
||||
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
|
||||
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
|
||||
.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; }
|
||||
}
|
||||
/* a list of switches or a template with its chips is a row of its own, however
|
||||
many columns the group lays its other fields out in */
|
||||
.tile-settings .field-group-body.is-columns .multiselect-field,
|
||||
.tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
|
||||
.tile-settings .field-group-help,
|
||||
.tile-settings .field-help {
|
||||
display: block;
|
||||
@@ -635,14 +793,64 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* boolean / switch settings: label on the left, switch on the right (help,
|
||||
when there is any, wraps under both) */
|
||||
.field.check {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 4px 10px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||
.field.check .check-label { flex: 1 1 0; min-width: 0; }
|
||||
.field.check .field-help { flex-basis: 100%; }
|
||||
|
||||
/* The switch: the admin page's module switch, same track, knob and colours,
|
||||
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
|
||||
off = hidden/disabled (border track, muted knob). The real checkbox lies
|
||||
invisibly over the track — still focusable, so Tab reaches it and Space
|
||||
flips it — and the label around it makes the text clickable too. */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.switch .track {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.switch input:checked + .track { background: var(--accent); }
|
||||
.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); }
|
||||
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* color fields: label on the left, swatch on the right */
|
||||
.field.color-field {
|
||||
@@ -708,7 +916,7 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #06281a;
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -729,6 +937,24 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
#toast[hidden] { display: none; }
|
||||
#toast button {
|
||||
margin-left: 12px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#toast button:hover { background: var(--accent); color: var(--accent-text); }
|
||||
#modal .modal-copy {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
#toast.error { border-color: var(--danger); }
|
||||
|
||||
/* ── scrollbars ────────────────────────────────────────────────────── */
|
||||
@@ -755,3 +981,163 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
@media (max-width: 560px) {
|
||||
#topbar { padding: 0 8px; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── themes ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
|
||||
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
|
||||
variables the module guide names — nothing else — so every module and
|
||||
every shell element follows, and a colour a tile is configured with (set
|
||||
on the tile itself) still wins. The :root block at the top of this file is
|
||||
"booth", the default; the list of ids and names lives in server.js
|
||||
(GET /api/theme). Every one is booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable.
|
||||
Each sets --accent-text too when the default (a near-black green, for
|
||||
booth's mint) would look wrong against its own accent. */
|
||||
|
||||
/* Harbor — deep navy, cyan accent */
|
||||
html[data-theme="harbor"] {
|
||||
--bg: #06101f;
|
||||
--panel: #0c1a30;
|
||||
--border: #1a2b48;
|
||||
--text: #e6eefb;
|
||||
--muted: #8ea4c8;
|
||||
--accent: #2ad4ee;
|
||||
--danger: #ff5a5f;
|
||||
--warn: #f7b53b;
|
||||
}
|
||||
|
||||
/* Graphite — neutral charcoal, electric-blue accent */
|
||||
html[data-theme="graphite"] {
|
||||
--bg: #0f1012;
|
||||
--panel: #17191d;
|
||||
--border: #2a2d33;
|
||||
--text: #f0f2f4;
|
||||
--muted: #9ca3ad;
|
||||
--accent: #6cb4ff;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #ffb224;
|
||||
}
|
||||
|
||||
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
|
||||
warn a pale yellow, so the three status colours never blur) */
|
||||
html[data-theme="ember"] {
|
||||
--bg: #120c0a;
|
||||
--panel: #1d1411;
|
||||
--border: #35251e;
|
||||
--text: #f6ede6;
|
||||
--muted: #b39b8c;
|
||||
--accent: #ff8f3a;
|
||||
--danger: #f03247;
|
||||
--warn: #ffd86b;
|
||||
}
|
||||
|
||||
/* Daylight — the light one, for a front-of-house desk in daytime */
|
||||
html[data-theme="daylight"] {
|
||||
--bg: #eef1f5;
|
||||
--panel: #ffffff;
|
||||
--border: #cfd6e0;
|
||||
--text: #14181f;
|
||||
--muted: #5b6675;
|
||||
--accent: #0b8a5f;
|
||||
--accent-text: #ffffff; /* dark text drowns on this accent */
|
||||
--danger: #d1242f;
|
||||
--warn: #b26a00;
|
||||
}
|
||||
|
||||
/* Midnight — indigo night, violet accent */
|
||||
html[data-theme="midnight"] {
|
||||
--bg: #0a0b18;
|
||||
--panel: #14162b;
|
||||
--border: #272a4a;
|
||||
--text: #e8e9fb;
|
||||
--muted: #9a9cc8;
|
||||
--accent: #9d8cff;
|
||||
--accent-text: #12103a;
|
||||
--danger: #ff5a72;
|
||||
--warn: #f5b93a;
|
||||
}
|
||||
|
||||
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
|
||||
and the amber never read as one another) */
|
||||
html[data-theme="moss"] {
|
||||
--bg: #08120c;
|
||||
--panel: #101e15;
|
||||
--border: #1f3728;
|
||||
--text: #e9f4ea;
|
||||
--muted: #8fb09a;
|
||||
--accent: #8fdb3f;
|
||||
--accent-text: #15250a;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Plum — aubergine, purple accent */
|
||||
html[data-theme="plum"] {
|
||||
--bg: #110a17;
|
||||
--panel: #1d1228;
|
||||
--border: #332042;
|
||||
--text: #f2e9f8;
|
||||
--muted: #b195c2;
|
||||
--accent: #c084fc;
|
||||
--accent-text: #260a37;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #f7b53b;
|
||||
}
|
||||
|
||||
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
|
||||
Ember, which is warm throughout */
|
||||
html[data-theme="sepia"] {
|
||||
--bg: #12100b;
|
||||
--panel: #1d1913;
|
||||
--border: #33291d;
|
||||
--text: #f4ede0;
|
||||
--muted: #b3a48b;
|
||||
--accent: #5eead4;
|
||||
--accent-text: #06231e;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ffc247;
|
||||
}
|
||||
|
||||
/* Blackout — true black for a dark room, where any glow off the screen
|
||||
carries; everything else is as bright as it can be against it */
|
||||
html[data-theme="blackout"] {
|
||||
--bg: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--border: #1f1f1f;
|
||||
--text: #ffffff;
|
||||
--muted: #9e9e9e;
|
||||
--accent: #00e5ff;
|
||||
--accent-text: #00212a;
|
||||
--danger: #ff3b30;
|
||||
--warn: #ffcc00;
|
||||
}
|
||||
|
||||
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
|
||||
darken, as they must on a light background, and the indigo accent takes
|
||||
white text. */
|
||||
html[data-theme="paper"] {
|
||||
--bg: #f5f1e8;
|
||||
--panel: #fffdf8;
|
||||
--border: #ddd5c4;
|
||||
--text: #1c1a15;
|
||||
--muted: #6b6455;
|
||||
--accent: #4f46e5;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c8102e;
|
||||
--warn: #9a6700;
|
||||
}
|
||||
|
||||
/* Iron — no colour of its own: the accent is near-white, so the only hues on
|
||||
screen are a module's own and the red/amber that mean something */
|
||||
html[data-theme="iron"] {
|
||||
--bg: #0c0c0d;
|
||||
--panel: #161617;
|
||||
--border: #2b2b2d;
|
||||
--text: #f2f2f3;
|
||||
--muted: #9a9a9d;
|
||||
--accent: #d8d8dc;
|
||||
--accent-text: #131314;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #ffb224;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user