Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d3237328 | ||
|
|
67f612e371 | ||
|
|
0f0adb4c60 |
@@ -14,8 +14,6 @@ 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/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
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/` (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
|
||||
`/admin`, modules in `modules/`, 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
|
||||
@@ -29,12 +28,7 @@ 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()`. **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"`
|
||||
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
|
||||
and bump the module's `version` on every change.
|
||||
- Browsers never call upstream services directly; the module's server routes
|
||||
proxy everything (no CORS, secrets stay server-side).
|
||||
@@ -51,7 +45,6 @@ title-bar controls). The rules that matter most:
|
||||
`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)
|
||||
|
||||
|
||||
@@ -30,23 +30,11 @@ 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
|
||||
|
||||
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
|
||||
Open the disk image 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. 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
|
||||
nothing else has to be installed and nothing can be missing. 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
|
||||
@@ -55,26 +43,12 @@ 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` (`ARCH=x86_64` in front of it
|
||||
for Intel):
|
||||
[launcher/macos](launcher/macos)/`build.sh`:
|
||||
|
||||
```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.
|
||||
@@ -205,15 +179,13 @@ 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. 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.
|
||||
restart is needed. Modules that ship with your copy of ProdDash are
|
||||
"bundled"; ones you installed are "installed"; if both exist, the newer
|
||||
version runs.
|
||||
- **Uninstall** on a module card removes an installed module's files. For a
|
||||
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.
|
||||
bundled module (its folder belongs to the app) it hides the module from
|
||||
every dashboard instead; it reappears under Available modules and can be
|
||||
brought back without downloading. Settings are kept either way.
|
||||
- **Module updates**: when the repo has a newer version of an installed
|
||||
module, its card shows **Update to vX**.
|
||||
- **Version requirements**: every module declares the ProdDash versions it
|
||||
@@ -331,11 +303,9 @@ below. Nothing is installed without someone clicking.
|
||||
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), 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
|
||||
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
|
||||
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
|
||||
@@ -353,11 +323,7 @@ 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** — 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
|
||||
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||
plan item with its scheduled start time, length, type, song key,
|
||||
description and item notes. When the ProPresenter module is running it
|
||||
follows that module's live item, highlights the matching plan item, dims
|
||||
@@ -376,36 +342,6 @@ below. Nothing is installed without someone clicking.
|
||||
(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
|
||||
|
||||
+8
-84
@@ -28,10 +28,7 @@ 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. **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).
|
||||
is fine; a program someone launches by hand is not.
|
||||
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||||
manifest) and **bump your own `version`** whenever you change the module.
|
||||
The admin page refuses to install a module whose requirement the running
|
||||
@@ -86,86 +83,19 @@ 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. 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.
|
||||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||||
with the checkout) or into the data directory's `modules/` and restart. No
|
||||
shell code changes, ever. Modules bundled with a checkout and modules
|
||||
installed from the admin page live side by side; when both have the same id
|
||||
the newer version runs. **Uninstall** in the admin page removes an installed
|
||||
module's files (a bundled one is hidden instead, since its folder belongs to
|
||||
the app); its settings are kept for a later reinstall.
|
||||
|
||||
## module.json
|
||||
|
||||
@@ -754,7 +684,6 @@ clock, and any module that offers some. A module offers timers by:
|
||||
| `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. |
|
||||
@@ -882,11 +811,6 @@ 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,7 +51,6 @@ 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)
|
||||
@@ -69,10 +68,9 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## Signing
|
||||
|
||||
|
||||
+25
-15
@@ -1,8 +1,6 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||
# 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).
|
||||
# Node runtime, with nothing to install first.
|
||||
#
|
||||
# ./build.sh build/ProdDash.app, self-contained
|
||||
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
|
||||
@@ -13,8 +11,6 @@
|
||||
# ./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
|
||||
@@ -31,13 +27,8 @@ 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="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||
ARCH="arm64"
|
||||
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"
|
||||
@@ -115,7 +106,7 @@ fi
|
||||
|
||||
# ── the server, and the Node that runs it ────────────────────────────────
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||
@@ -134,16 +125,25 @@ 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-$NODE_ARCH/bin/node"
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
echo " embedding ProdDash $VERSION"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
|
||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$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,6 +165,16 @@ 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"
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/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}'))"
|
||||
@@ -1,727 +0,0 @@
|
||||
/* 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 = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'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 };
|
||||
@@ -1,719 +0,0 @@
|
||||
'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`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,416 +0,0 @@
|
||||
/* 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); }
|
||||
+2
-159
@@ -1,9 +1,5 @@
|
||||
/* 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 →
|
||||
@@ -12,10 +8,6 @@
|
||||
(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 */
|
||||
@@ -23,13 +15,6 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
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;
|
||||
@@ -37,7 +22,6 @@ function createPlan({ 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 = `
|
||||
@@ -370,8 +354,7 @@ function createPlan({ root, moduleApi }) {
|
||||
if (on('showKey') && item.keyName) {
|
||||
const key = document.createElement('span');
|
||||
key.className = 'pp-key';
|
||||
// B♭, F♯m — the same notation the Song Key tile uses.
|
||||
key.textContent = prettyKey(item.keyName);
|
||||
key.textContent = item.keyName;
|
||||
titleLine.appendChild(key);
|
||||
}
|
||||
if (on('showNowIndicator')) {
|
||||
@@ -498,133 +481,6 @@ function createPlan({ 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 || {};
|
||||
@@ -663,12 +519,11 @@ function createPlan({ 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)}"]`);
|
||||
if (row) keepInView(row);
|
||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -743,7 +598,6 @@ function createPlan({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function tick() {
|
||||
renderAbleSet();
|
||||
if (!state?.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
@@ -755,7 +609,6 @@ function createPlan({ 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();
|
||||
}
|
||||
@@ -776,16 +629,6 @@ function createPlan({ 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/* 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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/* 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}`;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.9.0",
|
||||
"version": "1.6.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||
"description": "Order of service from Planning Center Services",
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
@@ -382,275 +382,6 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-521
@@ -9,10 +9,6 @@
|
||||
*
|
||||
* GET /state → { state, now } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* plus `ableset` events: the song AbleSet is playing, its
|
||||
* sections and playhead, when there is an AbleSet module
|
||||
* to follow (see below). Its own event so the playhead
|
||||
* never drags the plan onto the wire with it.
|
||||
* POST /refresh → re-read the plan now
|
||||
* GET /service-types → { options } for the admin "Service type" select,
|
||||
* grouped by Planning Center folder path
|
||||
@@ -21,8 +17,6 @@
|
||||
* (documents ProPresenter has shown or lists — see below)
|
||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||||
* the service type's item note categories, by name
|
||||
* GET /teams → { options } for a Teams tile's "Teams" switches
|
||||
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
|
||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||
*
|
||||
@@ -35,14 +29,6 @@
|
||||
* GET …/plans/{id}/plan_times (service start times)
|
||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||
* GET /service_types/{st}/item_note_categories (note category names)
|
||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
||||
*
|
||||
* Following AbleSet: the same loopback trick, entirely optional. When this
|
||||
* ProdDash has an AbleSet module that is configured and answering, its song,
|
||||
* sections and playhead are passed to the tiles, and the song is matched to a
|
||||
* plan item by name so a tile can fill that item as the song plays. With no
|
||||
* such module — not installed, disabled, no host — tiles are told
|
||||
* `available: false` and draw none of it.
|
||||
*
|
||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||
* this module reads the propresenter-now-next module's own SSE feed over
|
||||
@@ -70,7 +56,7 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
serviceTimes,
|
||||
@@ -96,28 +82,9 @@ const HEARTBEAT_MS = 15000;
|
||||
/** The Now/Next module whose feed we follow. */
|
||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||
const NOW_NEXT_RETRY_MS = 3000;
|
||||
/** The AbleSet module, followed the same way — entirely optional (see below). */
|
||||
const ABLESET_ID = 'ableset';
|
||||
const ABLESET_RETRY_MS = 3000;
|
||||
const ABLESET_MISSING_RETRY_MS = 15000;
|
||||
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
|
||||
const ABLESET_POS_MS = 200;
|
||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||
const SEEN_DOCUMENTS_MAX = 200;
|
||||
/**
|
||||
* Team assignments change far more slowly than the plan itself (someone
|
||||
* confirms, a position is filled), so they ride a slow lane: re-read when the
|
||||
* plan changes, and otherwise no more often than this.
|
||||
*/
|
||||
const TEAMS_MIN_MS = 60 * 1000;
|
||||
/** How long the service type's own team list is believed. */
|
||||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
||||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
||||
const AVATAR_CACHE_MAX = 300;
|
||||
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||||
/**
|
||||
@@ -145,8 +112,6 @@ function clearedState() {
|
||||
serviceType: { id: '', name: '' },
|
||||
plan: null,
|
||||
items: [],
|
||||
teams: [], // who is scheduled, by team (the Teams tile)
|
||||
teamsError: '', // why the last team read failed, if it did
|
||||
live: clearedLive(),
|
||||
service: clearedService(),
|
||||
timing: null, // the admin's service-timing choices (no secrets)
|
||||
@@ -170,28 +135,6 @@ function clearedLive() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What tiles are told about AbleSet. `available` false covers every way there
|
||||
* may be nothing to draw — no module installed, disabled, or not answering —
|
||||
* and the tiles then show none of it at all.
|
||||
*/
|
||||
function clearedAbleSet() {
|
||||
return {
|
||||
available: false,
|
||||
configured: false,
|
||||
reachable: false,
|
||||
reason: '',
|
||||
playing: false,
|
||||
tempo: null,
|
||||
song: null,
|
||||
section: null,
|
||||
sections: [],
|
||||
beats: null,
|
||||
itemId: '',
|
||||
at: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The service that owns the clock, as tiles and the timers see it. */
|
||||
function clearedService() {
|
||||
return {
|
||||
@@ -429,168 +372,6 @@ function shapePlan(planRec, serviceTypeId, planTimes) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── team assignments (the Teams tile) ──────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Planning Center's scheduling status letters. The API answers "C", "U" or
|
||||
* "D"; some accounts and older records spell the word out, so match on the
|
||||
* first letter and treat anything unknown as not yet confirmed — the state a
|
||||
* booth should chase.
|
||||
*/
|
||||
function teamStatusOf(raw) {
|
||||
const s = String(raw || '').trim().toUpperCase();
|
||||
if (s.startsWith('C')) return 'confirmed';
|
||||
if (s.startsWith('D')) return 'declined';
|
||||
return 'unconfirmed';
|
||||
}
|
||||
|
||||
/**
|
||||
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
|
||||
* plan is read with `include=person` the Person record carries the fuller set
|
||||
* of names Planning Center has used for the same thing over the years. Take
|
||||
* the first that looks like a URL — a missing photo is normal and the tile
|
||||
* draws initials instead.
|
||||
*/
|
||||
function teamPhotoOf(attrs, person) {
|
||||
const candidates = [
|
||||
attrs?.photo_thumbnail,
|
||||
attrs?.photo_url,
|
||||
person?.attributes?.avatar,
|
||||
person?.attributes?.photo_thumbnail_url,
|
||||
person?.attributes?.photo_url,
|
||||
person?.attributes?.thumbnail_url,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const url = String(c || '').trim();
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
|
||||
* each with its people. Teams keep Planning Center's own order (a Team's
|
||||
* `sequence`, then name); people sort by position, then by name, so a tile
|
||||
* reads the way the schedule page does.
|
||||
*
|
||||
* Everyone scheduled belongs to a team, but a record whose team relationship
|
||||
* is missing (a deleted team, a partial response) is not dropped on the
|
||||
* floor — it lands under "Unassigned" where somebody will notice it.
|
||||
*
|
||||
* `allTeams` is the service type's own team list. Planning Center only
|
||||
* returns a team here once somebody is scheduled on it, so without that list
|
||||
* a team nobody has signed up for is invisible — which is the one a booth
|
||||
* most wants to see. It also carries the authoritative name and order.
|
||||
*/
|
||||
function shapeTeams(body, allTeams) {
|
||||
const included = new Map();
|
||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
|
||||
const teams = new Map();
|
||||
for (const pp of body.data || []) {
|
||||
const a = pp.attributes || {};
|
||||
const teamRef = pp.relationships?.team?.data;
|
||||
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
|
||||
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
|
||||
if (!teams.has(teamId)) {
|
||||
teams.set(teamId, {
|
||||
id: teamId,
|
||||
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
|
||||
sequence: Number(teamRec?.attributes?.sequence) || 0,
|
||||
members: [],
|
||||
});
|
||||
}
|
||||
const personRef = pp.relationships?.person?.data;
|
||||
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
|
||||
teams.get(teamId).members.push({
|
||||
id: String(pp.id),
|
||||
personId: personRef?.id ? String(personRef.id) : '',
|
||||
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
|
||||
position: String(a.team_position_name || '').trim(),
|
||||
status: teamStatusOf(a.status),
|
||||
declineReason: String(a.decline_reason || '').trim(),
|
||||
notes: String(a.notes || '').trim(),
|
||||
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
|
||||
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
|
||||
});
|
||||
}
|
||||
|
||||
// Teams nobody is scheduled on yet, and the real name/order for the rest.
|
||||
for (const t of allTeams || []) {
|
||||
const known = teams.get(t.id);
|
||||
if (known) {
|
||||
if (t.name) known.name = t.name;
|
||||
known.sequence = t.sequence;
|
||||
} else {
|
||||
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const out = [...teams.values()];
|
||||
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
|
||||
for (const team of out) {
|
||||
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
|
||||
team.counts = {
|
||||
total: team.members.length,
|
||||
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
|
||||
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
|
||||
declined: team.members.filter((m) => m.status === 'declined').length,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
|
||||
ever talks to Planning Center (same reasoning as every other upstream). */
|
||||
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
|
||||
const avatarInFlight = new Map();
|
||||
|
||||
function fetchAvatar(url) {
|
||||
const hit = avatarCache.get(url);
|
||||
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
|
||||
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
|
||||
const p = (async () => {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
|
||||
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
|
||||
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
|
||||
const entry = { buf, type, at: Date.now() };
|
||||
avatarCache.set(url, entry);
|
||||
return entry;
|
||||
})().finally(() => avatarInFlight.delete(url));
|
||||
avatarInFlight.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
|
||||
function avatarUrlAllowed(url) {
|
||||
try {
|
||||
const u = new URL(String(url || ''));
|
||||
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
|
||||
async function serveAvatar(res, url) {
|
||||
if (!avatarUrlAllowed(url)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||
return void res.end('No photo');
|
||||
}
|
||||
try {
|
||||
const img = await fetchAvatar(url);
|
||||
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
|
||||
res.end(img.buf);
|
||||
} catch {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||
res.end('Photo unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
|
||||
function serviceLabel(win) {
|
||||
if (!win || !win.startsAt) return '';
|
||||
@@ -768,98 +549,6 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
/* — team assignments (the Teams tile) — */
|
||||
|
||||
/**
|
||||
* The service type's teams, cached: they change when somebody edits the
|
||||
* service type, not during a service. A failed read is not fatal — the
|
||||
* plan's own teams still draw, just without the empty ones.
|
||||
*/
|
||||
let serviceTeams = { at: 0, list: [] };
|
||||
|
||||
async function readServiceTeams() {
|
||||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
||||
try {
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
||||
const list = (body.data || []).map((t, i) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
||||
sequence: Number(t.attributes?.sequence) || i + 1,
|
||||
}));
|
||||
serviceTeams = { at: Date.now(), list };
|
||||
} catch (err) {
|
||||
log(`service type team list read failed: ${err?.message || err}`);
|
||||
serviceTeams.at = Date.now(); // don't retry every poll
|
||||
}
|
||||
return serviceTeams.list;
|
||||
}
|
||||
|
||||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
||||
let teamPhotos = new Map();
|
||||
let teamsReadAt = 0;
|
||||
|
||||
/**
|
||||
* Who is scheduled on this plan. Asked for with `include=team,person` and
|
||||
* read again defensively without the includes when an account refuses
|
||||
* them, exactly as the items read does: a tile that can name people
|
||||
* without photos is far better than a tile that shows nothing.
|
||||
*/
|
||||
async function readTeams(planId) {
|
||||
const target = `${planPath(planId)}/team_members`;
|
||||
let body;
|
||||
try {
|
||||
body = await pco.getAll(target, { include: 'team,person' });
|
||||
} catch (err) {
|
||||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||||
log(`team include rejected (${err.message}) — reading team members without includes`);
|
||||
body = await pco.getAll(target);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const teams = shapeTeams(body, await readServiceTeams());
|
||||
const photos = new Map();
|
||||
const included = new Map();
|
||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
for (const pp of body.data || []) {
|
||||
const personRef = pp.relationships?.person?.data;
|
||||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
||||
if (url) photos.set(String(pp.id), url);
|
||||
}
|
||||
teamPhotos = photos;
|
||||
teamsReadAt = Date.now();
|
||||
return teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the team list when it is worth it: a new plan, or the slow lane
|
||||
* coming round. A failure keeps the last good list and says so in the
|
||||
* state — the plan itself is what the booth needs, and losing the roster
|
||||
* must never cost them the running order.
|
||||
*/
|
||||
async function refreshTeams(planId, force) {
|
||||
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
|
||||
try {
|
||||
const teams = await readTeams(planId);
|
||||
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
|
||||
const after = teams.reduce((n, t) => n + t.members.length, 0);
|
||||
state.teams = teams;
|
||||
state.teamsError = '';
|
||||
if (force || before !== after) {
|
||||
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
state.teamsError = err?.message || String(err);
|
||||
log(`team read failed: ${state.teamsError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
||||
function teamOptions() {
|
||||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
||||
}
|
||||
|
||||
|
||||
async function readPlan() {
|
||||
if (!pco.configured || !serviceTypeId) return;
|
||||
let planRec = null;
|
||||
@@ -884,8 +573,6 @@ module.exports = {
|
||||
if (state.plan) log('no plan found any more — service timing reset');
|
||||
state.plan = null;
|
||||
state.items = [];
|
||||
state.teams = [];
|
||||
state.teamsError = "";
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -902,8 +589,6 @@ module.exports = {
|
||||
state.plan = plan;
|
||||
state.items = items;
|
||||
noteMessageItem(items);
|
||||
// The roster rides its own slow lane and never fails the plan read.
|
||||
await refreshTeams(plan.id, planChanged);
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -1297,170 +982,6 @@ module.exports = {
|
||||
const badPorts = new Set();
|
||||
|
||||
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
||||
|
||||
/* — AbleSet, over loopback like the ProPresenter feed above — */
|
||||
|
||||
/**
|
||||
* The AbleSet module, if this ProdDash has one. Nothing here is required:
|
||||
* a dashboard with no AbleSet module, a disabled one, or one with no host
|
||||
* configured simply reports `available: false` and the tiles draw none of
|
||||
* it. Installing AbleSet later lights it up without a restart, because a
|
||||
* 404 is retried on the slow timer like any other missing feed.
|
||||
*
|
||||
* What tiles get is deliberately small — the song, its sections, and the
|
||||
* playhead in beats with a stamp — because it moves five times a second
|
||||
* and must never drag the plan (items, teams) onto the wire with it. It
|
||||
* rides its own `ableset` event on the module's one stream.
|
||||
*/
|
||||
let absRequest = null;
|
||||
let absRetryTimer = null;
|
||||
let absPosTimer = null;
|
||||
let absLastSent = 0;
|
||||
let absLastStill = '';
|
||||
let abs = clearedAbleSet();
|
||||
|
||||
function absPart(p) {
|
||||
if (!p || typeof p !== 'object') return null;
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return null;
|
||||
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
||||
return { name, index: n(p.index), start: n(p.start), end: n(p.end) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plan item AbleSet's song is, by the same name matching the live
|
||||
* ProPresenter document uses. Without this a progress bar would happily
|
||||
* fill the sermon while the band rehearses the next song: the tile only
|
||||
* draws progress when the song AbleSet is playing IS the item on screen.
|
||||
*/
|
||||
function absItemFor(songName) {
|
||||
if (!songName) return '';
|
||||
let best = null;
|
||||
for (const item of state.items) {
|
||||
if (item.itemType === 'header') continue;
|
||||
const score = Math.max(
|
||||
similarity(songName, item.title),
|
||||
item.song ? similarity(songName, item.song.title) : 0
|
||||
);
|
||||
if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score };
|
||||
}
|
||||
return best ? best.id : '';
|
||||
}
|
||||
|
||||
function applyAbleSet(view) {
|
||||
const song = absPart(view?.song);
|
||||
abs = {
|
||||
available: true,
|
||||
configured: Boolean(view?.configured),
|
||||
reachable: Boolean(view?.reachable),
|
||||
playing: Boolean(view?.playing),
|
||||
tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null,
|
||||
song,
|
||||
section: absPart(view?.section),
|
||||
sections: Array.isArray(view?.sections)
|
||||
? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null }))
|
||||
: [],
|
||||
beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null,
|
||||
itemId: song ? absItemFor(song.name) : '',
|
||||
at: Date.now(),
|
||||
};
|
||||
broadcastAbleSet();
|
||||
}
|
||||
|
||||
/** Only the playhead moved: keep everything else and restamp. */
|
||||
function applyAbleSetPos(pos) {
|
||||
if (!abs.available) return;
|
||||
abs = {
|
||||
...abs,
|
||||
beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats,
|
||||
at: Date.now(),
|
||||
};
|
||||
broadcastAbleSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan the slice out. A change in anything but the playhead goes at once;
|
||||
* the playhead itself is throttled, since AbleSet offers more packets
|
||||
* than any tile can draw.
|
||||
*/
|
||||
function broadcastAbleSet(force) {
|
||||
const { beats, at, ...still } = abs;
|
||||
const frame = JSON.stringify(still);
|
||||
const changed = frame !== absLastStill;
|
||||
absLastStill = frame;
|
||||
const now = Date.now();
|
||||
if (!force && !changed && now - absLastSent < ABLESET_POS_MS) {
|
||||
// Due soon anyway — make sure the last packet of a burst still lands.
|
||||
if (!absPosTimer) {
|
||||
absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS);
|
||||
absPosTimer.unref?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearTimeout(absPosTimer);
|
||||
absPosTimer = null;
|
||||
absLastSent = now;
|
||||
if (!streams.size) return;
|
||||
writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`);
|
||||
}
|
||||
|
||||
function absUnavailable(reason, retryMs) {
|
||||
if (abs.available || abs.reason !== reason) {
|
||||
abs = { ...clearedAbleSet(), reason };
|
||||
broadcastAbleSet(true);
|
||||
}
|
||||
if (stopped) return;
|
||||
clearTimeout(absRetryTimer);
|
||||
absRetryTimer = setTimeout(connectAbleSet, retryMs);
|
||||
absRetryTimer.unref?.();
|
||||
}
|
||||
|
||||
function connectAbleSet() {
|
||||
if (stopped) return;
|
||||
const port = nnPort;
|
||||
const req = http.get(
|
||||
{ host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return absUnavailable(
|
||||
res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`,
|
||||
ABLESET_MISSING_RETRY_MS
|
||||
);
|
||||
}
|
||||
res.setEncoding('utf8');
|
||||
let buffer = '';
|
||||
res.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
try {
|
||||
if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data));
|
||||
else if (event === 'pos') applyAbleSetPos(JSON.parse(data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
}
|
||||
});
|
||||
res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS));
|
||||
res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS));
|
||||
}
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
if (req !== absRequest) return; // superseded by a reconnect on the learned port
|
||||
absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS);
|
||||
});
|
||||
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||
absRequest = req;
|
||||
}
|
||||
|
||||
function learnPort(hostHeader) {
|
||||
if (shellPort) return; // the shell told us; nothing to learn
|
||||
const port = portFromHost(hostHeader);
|
||||
@@ -1472,10 +993,6 @@ module.exports = {
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
nnRequest = null;
|
||||
connectNowNext();
|
||||
clearTimeout(absRetryTimer);
|
||||
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||
absRequest = null;
|
||||
connectAbleSet();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1734,9 +1251,6 @@ module.exports = {
|
||||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||
connectNowNext();
|
||||
}
|
||||
// Always: whether there is an AbleSet module to follow is its answer, not
|
||||
// an admin setting here.
|
||||
connectAbleSet();
|
||||
mirrorService();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
@@ -1769,7 +1283,6 @@ module.exports = {
|
||||
timerStreams,
|
||||
pco,
|
||||
getState: () => state,
|
||||
getAbleSet: () => abs,
|
||||
learnPort,
|
||||
refresh() {
|
||||
clearTimeout(pollTimer);
|
||||
@@ -1780,8 +1293,6 @@ module.exports = {
|
||||
timersSnapshot,
|
||||
documentOptions,
|
||||
noteCategoryOptions,
|
||||
teamOptions,
|
||||
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1791,10 +1302,7 @@ module.exports = {
|
||||
clearInterval(ticker);
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(nnRetryTimer);
|
||||
clearTimeout(absRetryTimer);
|
||||
clearTimeout(absPosTimer);
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||
for (const set of [streams, timerStreams]) {
|
||||
for (const res of set) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
@@ -1836,11 +1344,7 @@ module.exports = {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
current?.learnPort(req.headers.host);
|
||||
json(res, 200, {
|
||||
state: current ? current.getState() : clearedState(),
|
||||
ableset: current ? current.getAbleSet() : clearedAbleSet(),
|
||||
now: Date.now(),
|
||||
});
|
||||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
@@ -1848,7 +1352,6 @@ module.exports = {
|
||||
sseHead(res);
|
||||
const state = current ? current.getState() : clearedState();
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||
res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
@@ -1882,28 +1385,6 @@ module.exports = {
|
||||
req.on('close', () => timerStreams.delete(res));
|
||||
},
|
||||
|
||||
/**
|
||||
* Options for a Teams tile's "Teams" switches. Like the note
|
||||
* categories, always a 200 with a list: an empty one simply means there
|
||||
* is nothing scheduled to choose from yet, which the gear window says
|
||||
* plainly.
|
||||
*/
|
||||
'GET /teams': (req, res) => {
|
||||
json(res, 200, current ? current.teamOptions() : { options: [] });
|
||||
},
|
||||
|
||||
/**
|
||||
* A scheduled person's photo, proxied from Planning Center and cached.
|
||||
* The tile asks by plan-person id and never sees the CDN URL; an id the
|
||||
* module does not know (an old tile, a person since unscheduled) is a
|
||||
* 404 and the tile falls back to initials.
|
||||
*/
|
||||
'GET /team-avatar/*': (req, res) => {
|
||||
const id = String(req.wildcard || '').replace(/^\/+/, '');
|
||||
const url = current ? current.avatarUrlFor(id) : '';
|
||||
serveAvatar(res, url);
|
||||
},
|
||||
|
||||
/** Options for the admin "Start document" / "End document" selects. */
|
||||
'GET /documents': (req, res) => {
|
||||
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
||||
|
||||
+1
-380
@@ -86,7 +86,6 @@
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
@@ -206,9 +205,7 @@
|
||||
|
||||
[data-module="pco-plan"] .pp-now-tag {
|
||||
display: none;
|
||||
/* 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;
|
||||
padding: 0.05em 0.45em;
|
||||
border-radius: 999px;
|
||||
background: var(--pp-current);
|
||||
color: var(--bg);
|
||||
@@ -368,379 +365,3 @@
|
||||
[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; }
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
/* 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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -70,7 +70,6 @@ const DEFAULTS = {
|
||||
showDetail: true,
|
||||
showSeconds: true,
|
||||
leadingZeros: false,
|
||||
showLoops: false,
|
||||
clock24h: true,
|
||||
clockSeconds: true,
|
||||
clockDate: false,
|
||||
@@ -124,13 +123,10 @@ export function framesToTc(n, nominal, df) {
|
||||
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
||||
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
||||
* forward while running; `startedAt` is the fallback when they are absent.
|
||||
* A timer measured at its own instant carries `asOf` (a phase-locked
|
||||
* ProPresenter value — see the server's phase.js) which beats the source's
|
||||
* snapshot time.
|
||||
*/
|
||||
export function valueAt(timer, sourceUpdatedAt, now) {
|
||||
const running = timer.state === 'running';
|
||||
const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now;
|
||||
const at = Number(sourceUpdatedAt) || now;
|
||||
const dt = running ? Math.max(0, now - at) : 0;
|
||||
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
||||
if (timer.kind === 'countdown') {
|
||||
@@ -385,12 +381,7 @@ export default function create({ root, moduleApi }) {
|
||||
for (const source of state.sources) {
|
||||
for (const timer of source.timers) {
|
||||
if (keyOf(source, timer) !== solo) continue;
|
||||
// A solo video tile with looping media hidden must not sit empty:
|
||||
// it shows a quiet card instead of a countdown nobody should read.
|
||||
const shown = timer.looping && pref('showLoops') !== true
|
||||
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
||||
: timer;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||
}
|
||||
}
|
||||
// The timer this tile was added for is not offered right now. Nothing is
|
||||
@@ -404,11 +395,7 @@ export default function create({ root, moduleApi }) {
|
||||
const items = [];
|
||||
for (const timer of source.timers) {
|
||||
const key = keyOf(source, timer);
|
||||
if (hidden.has(key)) continue;
|
||||
// A looping background's time means nothing — the card sits out
|
||||
// while a loop plays (per tile: the "Looping videos" switch).
|
||||
if (timer.looping && pref('showLoops') !== true) continue;
|
||||
items.push({ key, source, timer });
|
||||
if (!hidden.has(key)) items.push({ key, source, timer });
|
||||
}
|
||||
// Every timer of a live source hidden on purpose: nothing to say.
|
||||
if (!items.length && source.timers.length) continue;
|
||||
@@ -660,25 +647,9 @@ export default function create({ root, moduleApi }) {
|
||||
statusTone = timer.status?.tone || 'muted';
|
||||
}
|
||||
} else {
|
||||
let value = valueAt(timer, source.updatedAt, now);
|
||||
// A running card must never tick backwards: an anchor correction or
|
||||
// clock-offset wobble can re-place a second boundary by a few
|
||||
// hundred ms, and a digit that regresses reads as broken. A small
|
||||
// step against the direction of travel holds the shown value (time
|
||||
// re-passes it within the jitter bound); a big one is a real
|
||||
// reset/seek and snaps.
|
||||
if (timer.state === 'running' && value !== null) {
|
||||
const dir = timer.kind === 'countdown' ? -1 : 1;
|
||||
const prev = card.mono;
|
||||
if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev;
|
||||
card.mono = value;
|
||||
} else {
|
||||
card.mono = null;
|
||||
}
|
||||
const value = valueAt(timer, source.updatedAt, now);
|
||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||
// A shown loop never warns or overruns — it wraps at zero by design,
|
||||
// and a background flashing red every pass would cry wolf all service.
|
||||
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
||||
tone = toneFor(timer, value);
|
||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||
statusTone = timer.status?.tone || tone;
|
||||
detailText = timer.detail || '';
|
||||
|
||||
@@ -10,23 +10,13 @@
|
||||
* onUpdate roughly 4×/s:
|
||||
*
|
||||
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
|
||||
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||
*
|
||||
* 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 binary is per-machine (gitignored), and a catalog install or
|
||||
* update ships source only — the update even wipes a previously built
|
||||
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
||||
* background as part of coming up: kicked when the LTC source starts and by
|
||||
* the admin device picker, never on the event loop (a blocking build once
|
||||
* froze the whole server — see spl-meter's history), built to a temp name
|
||||
* and renamed so a killed build can't leave a half-written "binary", and
|
||||
* rebuilt when a module update leaves the source newer than the binary.
|
||||
* A dead capture child is restarted with backoff — fast for a crash, slow
|
||||
* for config-shaped failures (unknown device, ambiguity) that never fix
|
||||
* themselves.
|
||||
* The capture binary is per-machine (gitignored): if it is missing this
|
||||
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||
*
|
||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||
@@ -37,7 +27,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
@@ -51,100 +41,6 @@ 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: build it when the install didn't bring one ──────── */
|
||||
|
||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||
const TOOL_BUILDING = 'Capture tool building…';
|
||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
||||
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
||||
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
||||
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
/**
|
||||
* Make sure the capture tool exists, building it in the background when it
|
||||
* does not — module scope, so the LTC source can kick it the moment it
|
||||
* starts and the admin device picker can kick it before any device is
|
||||
* configured (a listener-only build would deadlock: no tool → no device
|
||||
* list → no device → no listener). A catalog install ships source only and
|
||||
* a catalog update deletes the module folder (old binary included), so this
|
||||
* IS the install step for the tool.
|
||||
*
|
||||
* Never blocks (a synchronous build once held the whole server's event loop
|
||||
* — every module — for the duration). Builds to a temp name and renames, so
|
||||
* a killed or crashed build can't leave a truncated file that looks like a
|
||||
* ready binary. A binary older than the source (a module update over an
|
||||
* existing folder) keeps serving while a replacement builds behind it.
|
||||
*
|
||||
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
||||
* is usable right now; otherwise `error` is the short tile text and
|
||||
* `detail` the full one.
|
||||
*/
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'ltc-capture');
|
||||
const src = bin + '.swift';
|
||||
const ok = { ready: true, error: '', detail: '' };
|
||||
let haveBin = false;
|
||||
let stale = false;
|
||||
try {
|
||||
haveBin = fs.existsSync(bin);
|
||||
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
||||
} catch { /* treat as missing */ }
|
||||
if (haveBin && !stale) return ok;
|
||||
if (!fs.existsSync(src)) {
|
||||
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
||||
}
|
||||
const notYet = haveBin ? ok : {
|
||||
ready: false,
|
||||
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
||||
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
||||
};
|
||||
if (building || buildFailed) return notYet;
|
||||
|
||||
building = true;
|
||||
const tmp = bin + '.building';
|
||||
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
||||
: 'ltc-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
giveUp.unref?.();
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(tmp)) {
|
||||
try {
|
||||
fs.renameSync(tmp, bin);
|
||||
} catch (err) {
|
||||
buildFailed = true;
|
||||
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
buildFailed = false;
|
||||
log('ltc-capture built');
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return notYet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||
* onUpdate:(u:object)=>void}} opts
|
||||
@@ -179,24 +75,25 @@ 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 tool = ensureCaptureBinary({
|
||||
moduleDir,
|
||||
log,
|
||||
onReady() {
|
||||
// The build this start() was waiting on landed — go now.
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
},
|
||||
});
|
||||
if (!tool.ready) {
|
||||
report({ error: tool.error, detail: tool.detail });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
||||
// "Capture tool building…" forever, with a working binary on disk.
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
report({ error: missing });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
@@ -216,10 +113,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
const channels = Number(m[2]);
|
||||
const rate = Number(m[3]);
|
||||
if (channel > channels) {
|
||||
report({
|
||||
error: 'Channel not on this device — see Admin',
|
||||
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
|
||||
});
|
||||
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||
try { child.kill(); } catch { /* exiting anyway */ }
|
||||
return;
|
||||
}
|
||||
@@ -241,21 +135,17 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
||||
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
child = null;
|
||||
if (stopped) return;
|
||||
// The capture's own failure text (unknown device, ambiguous name,
|
||||
// permission hint) is the most useful DETAIL we have; the tile just
|
||||
// needs to know the input is down and where to fix it.
|
||||
// permission hint) is the most useful thing we can show.
|
||||
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||
const quickDeath = Date.now() - startedAt < 3000;
|
||||
report({
|
||||
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
|
||||
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
|
||||
});
|
||||
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||
});
|
||||
}
|
||||
@@ -298,4 +188,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||
module.exports = { createLtcListener };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.6.0",
|
||||
"version": "1.4.1",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
@@ -161,12 +161,6 @@
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLoops": {
|
||||
"type": "switch",
|
||||
"label": "Looping videos",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeadings": {
|
||||
"type": "switch",
|
||||
"label": "Source headings",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
'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 };
|
||||
@@ -1,205 +0,0 @@
|
||||
'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`);
|
||||
@@ -81,8 +81,7 @@
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||
const { createTimerPhase } = require('./phase');
|
||||
const { createLtcListener } = require('./ltc-listener');
|
||||
const manifest = require('./module.json');
|
||||
|
||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
||||
@@ -210,7 +209,6 @@ function sanitizeTimer(t) {
|
||||
label: String(t.label || id).slice(0, 120),
|
||||
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
||||
state: STATES.has(t.state) ? t.state : 'stopped',
|
||||
asOf: numOrNull(t.asOf),
|
||||
startedAt: numOrNull(t.startedAt),
|
||||
elapsedMs: numOrNull(t.elapsedMs),
|
||||
remainingMs: numOrNull(t.remainingMs),
|
||||
@@ -252,23 +250,6 @@ function createProPresenterSource({ config, log }) {
|
||||
let updatedAt = 0;
|
||||
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
||||
let timerConfigs = new Map();
|
||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||
const phases = new Map();
|
||||
|
||||
/* The video countdown — time left on what the presentation layer is
|
||||
playing, from the transport routes (verified live on ProPresenter 21.4):
|
||||
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
||||
artist, audio_only, duration } (no looping flag exists — inferred)
|
||||
GET /v1/transport/presentation/time → bare seconds, sub-second
|
||||
float — better than /v1/timer/video_countdown's whole-second string,
|
||||
so no quantization phase lock is needed, only an anchor hold. */
|
||||
let media = null; // { uuid, name, duration, playing } from the slow lane
|
||||
let mediaAt = 0;
|
||||
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
||||
let videoPos = null; // playback position in seconds (float)
|
||||
let videoAsOf = 0; // server ms when videoPos was measured
|
||||
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
||||
let loopingUuid = ''; // media seen wrapping — a looping background
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
@@ -344,21 +325,9 @@ function createProPresenterSource({ config, log }) {
|
||||
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
||||
state = atStart ? 'idle' : 'paused';
|
||||
}
|
||||
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
|
||||
// local count at the bottom of that second every poll made the digits run
|
||||
// ahead and flick back a step about once a second — phase.js turns the
|
||||
// reports into one stable anchor { valueMs, asOf } the tiles count from.
|
||||
let phase = phases.get(uuid);
|
||||
if (!phase) {
|
||||
phase = createTimerPhase();
|
||||
phases.set(uuid, phase);
|
||||
}
|
||||
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
|
||||
const v = anchored.valueMs;
|
||||
const asOf = anchored.asOf;
|
||||
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
||||
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
||||
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
|
||||
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
|
||||
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
||||
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
||||
return {
|
||||
@@ -366,7 +335,6 @@ function createProPresenterSource({ config, log }) {
|
||||
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
||||
kind,
|
||||
state,
|
||||
asOf,
|
||||
startedAt,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
@@ -399,143 +367,16 @@ function createProPresenterSource({ config, log }) {
|
||||
// Timers reported live but missing from the (possibly stale) config
|
||||
// list still deserve a card — type unknown until the slow lane catches up.
|
||||
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
|
||||
for (const uuid of phases.keys()) {
|
||||
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the presentation transport layer. Failures here only drop the
|
||||
* video card, never the whole source — the timers must survive a
|
||||
* ProPresenter build with transport quirks.
|
||||
*/
|
||||
async function pollVideo() {
|
||||
if (tick % SLOW_EVERY === 1) {
|
||||
try {
|
||||
const raw = await requestJson('/v1/transport/presentation/current');
|
||||
const uuid = String(raw?.uuid || '');
|
||||
if (!media || media.uuid !== uuid) {
|
||||
if (media && media.uuid !== uuid) loopingUuid = '';
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
}
|
||||
media = uuid ? {
|
||||
uuid,
|
||||
name: String(raw.name || ''),
|
||||
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
||||
playing: Boolean(raw.is_playing),
|
||||
} : null;
|
||||
mediaAt = Date.now();
|
||||
transportOk = true;
|
||||
} catch (err) {
|
||||
media = null; // the slow lane retries in ~2 s
|
||||
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
||||
}
|
||||
}
|
||||
if (!media) {
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
||||
if (Number.isFinite(pos) && pos >= 0) {
|
||||
// Same media, position jumped back by more than a stutter: it
|
||||
// wrapped — a looping background. Sticks until the media changes.
|
||||
// (A video manually re-triggered from the start looks the same and
|
||||
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
||||
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
||||
videoPos = pos;
|
||||
videoAsOf = Date.now();
|
||||
} else {
|
||||
videoPos = null;
|
||||
}
|
||||
} catch {
|
||||
videoPos = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The video countdown as one synthetic timer card (id 'video'). The card
|
||||
persists as an idle "—" between videos — a wall tile must not appear
|
||||
and vanish with the media — and is only absent entirely on a
|
||||
ProPresenter without transport routes. */
|
||||
function videoTimer() {
|
||||
if (transportOk !== true) return null;
|
||||
if (!media) {
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: 'idle',
|
||||
asOf: mediaAt || updatedAt,
|
||||
startedAt: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: null,
|
||||
targetMs: null,
|
||||
looping: false,
|
||||
status: null,
|
||||
detail: '',
|
||||
};
|
||||
}
|
||||
const playing = media.playing;
|
||||
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
||||
let remainingMs = null;
|
||||
let asOf = videoAsOf || mediaAt;
|
||||
if (videoPos !== null && dur !== null) {
|
||||
remainingMs = Math.max(0, dur - videoPos * 1000);
|
||||
if (playing) {
|
||||
// Hold the published anchor while it still predicts the measured
|
||||
// position: the tiles extrapolate locally, and a fresh anchor per
|
||||
// poll would broadcast a frame every 500 ms for nothing.
|
||||
const p = videoAnchor;
|
||||
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
||||
if (p && Math.abs(predicted - remainingMs) < 120) {
|
||||
remainingMs = p.remainingMs;
|
||||
asOf = p.asOf;
|
||||
} else {
|
||||
videoAnchor = { remainingMs, asOf };
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
const looping = media.uuid === loopingUuid;
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
||||
asOf,
|
||||
startedAt: null,
|
||||
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
||||
remainingMs,
|
||||
targetMs: dur,
|
||||
looping,
|
||||
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
||||
// The media name, without the file extension nobody reads on a wall.
|
||||
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function status() {
|
||||
if (!configured) return 'error';
|
||||
if (reachable) return 'ok';
|
||||
return lastError ? 'error' : 'connecting';
|
||||
}
|
||||
|
||||
/** Short, for the tiles — a dashboard warns, the admin page explains.
|
||||
The client prefixes the source label, so no "ProPresenter" here. */
|
||||
function tileMessage() {
|
||||
if (!configured) return 'Not configured — see Admin';
|
||||
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
return 'Unreachable';
|
||||
}
|
||||
|
||||
/** The full story, for the admin health line. */
|
||||
function adminMessage() {
|
||||
function message() {
|
||||
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
||||
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
||||
@@ -543,7 +384,7 @@ function createProPresenterSource({ config, log }) {
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
||||
emit({ status: status(), message: message(), timers, updatedAt });
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
@@ -559,9 +400,6 @@ function createProPresenterSource({ config, log }) {
|
||||
const currentTimes = await requestJson('/v1/timers/current');
|
||||
updatedAt = Date.now();
|
||||
timers = mergeTimers(currentTimes, updatedAt);
|
||||
await pollVideo();
|
||||
const video = videoTimer();
|
||||
if (video) timers.push(video);
|
||||
reachable = true;
|
||||
lastError = '';
|
||||
} catch (err) {
|
||||
@@ -592,7 +430,7 @@ function createProPresenterSource({ config, log }) {
|
||||
clearTimeout(pollTimer);
|
||||
},
|
||||
health() {
|
||||
return { status: status(), message: adminMessage() };
|
||||
return { status: status(), message: message() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -606,9 +444,7 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
let emit = () => {};
|
||||
let listener = null;
|
||||
let stopped = false;
|
||||
/** The listener's latest word: { state, time, fps, df, ageMs } |
|
||||
{ error, detail } (error = short tile text, detail = the admin/log
|
||||
story) | null while starting. */
|
||||
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
|
||||
let word = null;
|
||||
|
||||
function rateText() {
|
||||
@@ -660,16 +496,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
kind: 'ltc',
|
||||
start(e) {
|
||||
emit = e;
|
||||
// Kick the capture-tool build right away: a catalog install ships
|
||||
// source only and a catalog update wipes any previously built binary,
|
||||
// so coming up after an install/update must rebuild it without
|
||||
// waiting for a device to be picked or a listener to need it.
|
||||
ensureCaptureBinary({ moduleDir, log });
|
||||
if (!device) {
|
||||
word = {
|
||||
error: 'Not configured — see Admin',
|
||||
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
|
||||
};
|
||||
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
@@ -691,8 +519,7 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
listener = null;
|
||||
},
|
||||
health() {
|
||||
// The admin page gets the full story; the tiles got the short one.
|
||||
return { status: status(), message: (word && word.error && word.detail) || message() };
|
||||
return { status: status(), message: message() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -741,15 +568,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
let silenceTimer = null;
|
||||
let backoff = BACKOFF_MIN_MS;
|
||||
|
||||
/** Short, for the tiles; the lastError detail belongs to the admin line.
|
||||
The client prefixes the source label, so it isn't repeated here. */
|
||||
function tileMessage() {
|
||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
if (state === 'connecting') return 'Connecting…';
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
function adminMessage() {
|
||||
function message() {
|
||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
||||
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
||||
return `unreachable — ${lastError}`;
|
||||
@@ -757,7 +576,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
||||
emit({ label, status: state, message: message(), timers, updatedAt });
|
||||
}
|
||||
|
||||
function accept(raw) {
|
||||
@@ -935,7 +754,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
if (!stopped && !stream && via === 'poll') connectStream();
|
||||
},
|
||||
health() {
|
||||
return { status: state, message: adminMessage() };
|
||||
return { status: state, message: message() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1103,8 +922,6 @@ const GEAR = {
|
||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||
/** The video countdown: a timer card, plus whether looping media counts. */
|
||||
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||
/** The time of day: plain digits, so only how they read. */
|
||||
@@ -1118,7 +935,6 @@ function gearFor(source, timer) {
|
||||
if (source.kind === 'ltc') return GEAR.ltc;
|
||||
if (source.kind === 'clock') return GEAR.clock;
|
||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||
return GEAR.timer;
|
||||
}
|
||||
|
||||
@@ -1176,17 +992,12 @@ module.exports = {
|
||||
for (const t of v.timers) {
|
||||
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
|
||||
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
|
||||
// A timer measured at its own instant (asOf — a phase-locked
|
||||
// ProPresenter value) re-anchors from there; the rest from the
|
||||
// source snapshot. Consumers see plain contract values as of `now`.
|
||||
const at = Number(out.asOf) || v.updatedAt;
|
||||
if (t.state === 'running' && at > 0) {
|
||||
const dt = Math.max(0, now - at);
|
||||
if (t.state === 'running' && v.updatedAt > 0) {
|
||||
const dt = Math.max(0, now - v.updatedAt);
|
||||
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
|
||||
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
|
||||
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
|
||||
}
|
||||
delete out.asOf;
|
||||
timers.push(out);
|
||||
}
|
||||
}
|
||||
@@ -1327,8 +1138,7 @@ module.exports = {
|
||||
for (const t of src.timers) {
|
||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||
: src.kind === 'clock' ? 'The time of day on its own'
|
||||
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
||||
@@ -1337,7 +1147,7 @@ module.exports = {
|
||||
return out;
|
||||
},
|
||||
|
||||
routes({ log }) {
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
||||
@@ -1360,15 +1170,7 @@ module.exports = {
|
||||
|
||||
// The admin device picker asks this machine what it can hear. The
|
||||
// options shape matches the admin select's optionsRoute contract.
|
||||
// Opening the picker also kicks the capture-tool build when a catalog
|
||||
// install/update left only the source — without this, no tool meant
|
||||
// no device list, and no device meant nothing would ever build it.
|
||||
'GET /ltc/devices': (req, res) => {
|
||||
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (!tool.ready) {
|
||||
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
||||
return;
|
||||
}
|
||||
const bin = path.join(__dirname, 'ltc-capture');
|
||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||
let err = '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
|
||||
+94
-19
@@ -39,6 +39,9 @@ function msToPosition(total) {
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
|
||||
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
||||
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
@@ -61,6 +64,8 @@ function createPlayhead(options) {
|
||||
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 changes = 0; // readings that differed from the one before
|
||||
let rejected = false; // the last gap looked like a pause and was skipped
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
@@ -90,15 +95,25 @@ function createPlayhead(options) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) {
|
||||
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
|
||||
// from it to the next one is only a fraction of the real cadence. Learning
|
||||
// from it locked in a 2s cadence that rejected every honest 30s gap.
|
||||
if (lastNewAt !== undefined && changes > 0) {
|
||||
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.
|
||||
// But a pause is one gap; twice in a row is the cadence having changed.
|
||||
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);
|
||||
if (gap > 0 && gap < 60000) {
|
||||
if (!worst || gap <= worst * 3 || rejected) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
rejected = false;
|
||||
} else {
|
||||
rejected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) changes++;
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
@@ -118,7 +133,9 @@ function createPlayhead(options) {
|
||||
// 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));
|
||||
// Until a full gap has been measured the cadence is unknown; assume the slowest
|
||||
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
|
||||
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
@@ -161,13 +178,43 @@ function createResiClient(options) {
|
||||
return h;
|
||||
}
|
||||
|
||||
/* A failed login used to be retried on the very next poll -- once a second, for
|
||||
ever. Resi answers that with 429s, and since the retries never let up, the
|
||||
account never got out of them. So a failure now holds off further attempts,
|
||||
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
|
||||
call in between fails at once with the same reason instead of signing in. */
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
let loginFailures = 0;
|
||||
let loginBlockedUntil = 0;
|
||||
let loginError = '';
|
||||
|
||||
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})`);
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
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 }),
|
||||
});
|
||||
} catch (err) {
|
||||
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
|
||||
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
|
||||
: `Resi login failed (${res.status})`;
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff(reason, retryAfter * 1000);
|
||||
throw new Error(reason);
|
||||
}
|
||||
loginFailures = 0;
|
||||
loginBlockedUntil = 0;
|
||||
loginError = '';
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
@@ -179,24 +226,49 @@ function createResiClient(options) {
|
||||
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();
|
||||
loggedInAt = 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();
|
||||
function holdOff(reason, askedMs) {
|
||||
loginFailures++;
|
||||
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
|
||||
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
|
||||
loginError = reason;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
/* The same hold-off covers every request, not just the login. A 401/403 used to
|
||||
sign in again on the spot, every time: for a login that simply isn't allowed to
|
||||
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
|
||||
after signing in is taken as what it is -- a permission -- and held off like a
|
||||
failed login; only an older session is renewed. A 429 holds off too. */
|
||||
async function get(path) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
|
||||
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();
|
||||
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const where = path.includes('/venues') ? 'its venues'
|
||||
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
|
||||
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
@@ -216,7 +288,10 @@ function createResiClient(options) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
// Only an objection to the parameter itself; a refusal or a "slow down"
|
||||
// (401/403/429) is about the login, and dropping it then lost fresh readings.
|
||||
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
|
||||
if (!code || ['401', '403', '429'].includes(code)) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||
});
|
||||
|
||||
/* ── starting partway through Resi's cycle ─────────────────────────────── */
|
||||
|
||||
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
|
||||
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
|
||||
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
|
||||
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
|
||||
// and the next cue (and its name) stopped moving on.
|
||||
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
|
||||
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
|
||||
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
|
||||
w.lastPublish = w.t - into;
|
||||
const head = createPlayhead({ now: () => w.t });
|
||||
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||
let paused = 0;
|
||||
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
|
||||
if (burst) {
|
||||
w.media += 15 * 60000; // a seek, published at once...
|
||||
w.lastPublish = w.t - w.nextCadence;
|
||||
second(); second();
|
||||
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
|
||||
}
|
||||
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
|
||||
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
|
||||
@@ -70,6 +70,7 @@ function emptyTimers(now) {
|
||||
}
|
||||
|
||||
let current = null; // the live handle, shared with routes()
|
||||
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
@@ -146,7 +147,9 @@ module.exports = {
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
targetMs: Math.max(1000, state.next.ms - from),
|
||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
||||
// The label already is the cue's name, and it is what the card is titled
|
||||
// with; repeating it on the status line only crowded the card.
|
||||
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
|
||||
detail: state.event || '',
|
||||
});
|
||||
}
|
||||
@@ -271,6 +274,7 @@ module.exports = {
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
credentialsKey: `${username}\n${password}`,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
@@ -332,9 +336,21 @@ module.exports = {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
// The running module's client when it has these credentials, else one kept
|
||||
// per login: a fresh client per request signed in on every admin page load,
|
||||
// and its own hold-off never applied -- which kept Resi rate-limiting.
|
||||
const username = String(config.username).trim();
|
||||
const key = `${username}\n${config.password}`;
|
||||
let client;
|
||||
if (current && current.resi && current.credentialsKey === key) client = current.resi;
|
||||
else {
|
||||
if (!pickerClient || pickerClient.key !== key) {
|
||||
pickerClient = { key, client: createResiClient({
|
||||
username, password: String(config.password),
|
||||
}) };
|
||||
}
|
||||
client = pickerClient.client;
|
||||
}
|
||||
const options = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
||||
|
||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||
|
||||
test('signs in once and finds the configured decoder', () => {
|
||||
await 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);
|
||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.cues.length, 3);
|
||||
});
|
||||
|
||||
test('works out the cue either side of the playhead', () => {
|
||||
await 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', () => {
|
||||
await 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', () => {
|
||||
await 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');
|
||||
@@ -131,7 +132,7 @@ function collect(route, req) {
|
||||
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', () => {
|
||||
await 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');
|
||||
@@ -145,7 +146,7 @@ function collect(route, req) {
|
||||
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.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
|
||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||
|
||||
assert.strictEqual(playhead.id, 'playhead');
|
||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||
});
|
||||
|
||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
await 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();
|
||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
||||
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');
|
||||
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
|
||||
});
|
||||
|
||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
world.eventId = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
||||
assert.strictEqual(handle.state.event, '');
|
||||
});
|
||||
|
||||
test('health speaks about the upstream, not about itself', () => {
|
||||
await 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', () => {
|
||||
await 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');
|
||||
@@ -189,12 +190,90 @@ function collect(route, req) {
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
test('stop() leaves nothing running', () => {
|
||||
await test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||
});
|
||||
|
||||
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
|
||||
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
|
||||
await test('a failed login holds off instead of retrying every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let limited = true;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c',
|
||||
password: 'secret',
|
||||
now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
if (limited) return reply({}, { ok: false, status: 429 });
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /rate-limiting/);
|
||||
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
|
||||
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
|
||||
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
|
||||
|
||||
limited = false;
|
||||
t += 5 * 60000; // past the longest hold-off
|
||||
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
|
||||
const after = logins;
|
||||
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
|
||||
assert.strictEqual(logins, after, 'a good session is not signed in again');
|
||||
});
|
||||
|
||||
await test('a rejected password says so', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'wrong', now: () => 0,
|
||||
fetch: async () => reply({}, { ok: false, status: 400 }),
|
||||
});
|
||||
await assert.rejects(client.venues(), /check the password/);
|
||||
});
|
||||
|
||||
// Seen live: media@waters.church signs in fine but may not list venues (403). The
|
||||
// client answered every 403 by signing in again -- a fresh login on every poll.
|
||||
await test('a login that may not see venues is held off, not signed in every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let venueCalls = 0;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'secret', now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
|
||||
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
|
||||
// At most one renewal once the session is over a minute old (in case it really
|
||||
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
|
||||
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
|
||||
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
|
||||
assert.match(await poll(), /ask a Resi admin/);
|
||||
});
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
@@ -114,19 +114,11 @@ module.exports = {
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
// Before anything else: the device picker shells out to this tool, so a
|
||||
// machine that installed the module from the catalog (source only — the
|
||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
||||
// listener would never happen: no tool means no device list, and no
|
||||
// device means no listener.
|
||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
||||
|
||||
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 }) };
|
||||
@@ -185,9 +177,6 @@ module.exports = {
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||
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();
|
||||
@@ -311,13 +300,6 @@ module.exports = {
|
||||
/** 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 missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (missing) {
|
||||
if (state) state.toolMissing = missing;
|
||||
log(`device list unavailable — ${missing}`);
|
||||
json(res, 200, { options: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
@@ -456,10 +438,6 @@ function listDevices() {
|
||||
});
|
||||
}
|
||||
|
||||
function fsExists(p) {
|
||||
try { return require('fs').existsSync(p); } catch { return false; }
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||
* the app, or copied from any Mac of the same architecture. When it is
|
||||
* missing this will build it, but in the background and never on the event
|
||||
* loop, and from module scope so the device picker can trigger it too — see
|
||||
* ensureCaptureBinary for what both of those cost the first time round.
|
||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||
*
|
||||
* 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
|
||||
@@ -31,74 +30,6 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||
* everything that needs it.
|
||||
*
|
||||
* This lives at module scope rather than inside a listener because of a trap
|
||||
* the first version walked into: the admin page's device picker runs the tool
|
||||
* with --list, but the build only ran when a listener started, and a listener
|
||||
* only starts once a device has been picked. On a machine that got the module
|
||||
* from the catalog — source only, the binary being gitignored — the picker
|
||||
* was empty, and nothing would ever fill it.
|
||||
*
|
||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
||||
* listener where the source is a third the size and builds in seconds. Here
|
||||
* it took nearly two minutes on the production Mac and held the Node event
|
||||
* loop for all of it, freezing every other module, then did it again on the
|
||||
* next retry.
|
||||
*
|
||||
* The binary should normally already be there: built beside the app, or
|
||||
* copied from any Mac of the same architecture. This is the fallback.
|
||||
*
|
||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
||||
*/
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return BUILDING;
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})`
|
||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return BUILDING;
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -122,13 +53,64 @@ function createSplListener(opts) {
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
/** '' when the tool is ready, otherwise what to say about it. */
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
function ensureBinary() {
|
||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
} });
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
@@ -153,13 +135,9 @@ function createSplListener(opts) {
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
||||
// module: "building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,4 +236,4 @@ function createSplListener(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener, ensureCaptureBinary };
|
||||
module.exports = { createSplListener };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.10.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -443,7 +443,6 @@ 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; }
|
||||
|
||||
@@ -962,13 +962,6 @@ 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();
|
||||
@@ -1170,7 +1163,6 @@ 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';
|
||||
@@ -1213,46 +1205,11 @@ 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;
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
<section id="available-section" hidden>
|
||||
<h1 class="admin-h1">Available modules</h1>
|
||||
<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>
|
||||
|
||||
@@ -510,7 +510,6 @@ function reconcileOfferedTiles() {
|
||||
}
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
@@ -677,15 +676,6 @@ function buildTile(tile) {
|
||||
|
||||
function refreshEmptyState() {
|
||||
emptyEl.hidden = tiles.length > 0;
|
||||
if (emptyEl.hidden) return;
|
||||
// Two different empties: a bare board, and a ProdDash with nothing to put
|
||||
// on one — a packaged install starts without modules until Admin adds some.
|
||||
const noModules = registry.size === 0;
|
||||
if (noModules === emptyEl.classList.contains('no-modules')) return;
|
||||
emptyEl.classList.toggle('no-modules', noModules);
|
||||
emptyEl.innerHTML = noModules
|
||||
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
|
||||
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
|
||||
}
|
||||
|
||||
/* ── drag to move ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -277,52 +277,6 @@ function isUninstalled(id) {
|
||||
return moduleState(id).uninstalled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A packaged install that still carries modules in its app folder — every
|
||||
* install made before 1.10.0 does — moves them into the data directory
|
||||
* once, as installed copies, and drops the app-folder copies. Nothing
|
||||
* changes for the operator: the same modules run with the same settings
|
||||
* (config is keyed by module id), and from now on each updates on its own
|
||||
* from the catalog. A compiled helper sitting in a bundled folder travels
|
||||
* with it. A module the operator had uninstalled is simply dropped — the
|
||||
* catalog still offers it, and its settings stay in modules.json.
|
||||
*/
|
||||
function unbundleModules() {
|
||||
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
|
||||
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
|
||||
if (isUninstalled(man.id)) continue;
|
||||
const copy = installed.get(man.id);
|
||||
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
|
||||
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
|
||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
|
||||
try {
|
||||
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staging, dest);
|
||||
moved += 1;
|
||||
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
|
||||
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
|
||||
} catch (err) {
|
||||
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
function moduleState(id) {
|
||||
const s = modulesConfig[id];
|
||||
return s && typeof s === 'object' ? s : {};
|
||||
@@ -419,18 +373,7 @@ function mountModule(id) {
|
||||
const log = makeLog(id);
|
||||
try {
|
||||
const serverPath = path.join(man.dir, man.server);
|
||||
// Drop the module's code from the require cache so every (re)mount runs
|
||||
// the files on disk. Without this a module UPDATE swaps the folder while
|
||||
// the process keeps executing the old code until ProdDash itself
|
||||
// restarts — the manifest (read fresh from disk) then reports a version
|
||||
// the running code isn't, and anything new in the update silently
|
||||
// doesn't happen. Modules already have to come up clean on re-init
|
||||
// (module guide), so a fresh load on config remounts is the same
|
||||
// contract without the trap.
|
||||
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
|
||||
for (const key of Object.keys(require.cache)) {
|
||||
if (key.startsWith(cachePrefix)) delete require.cache[key];
|
||||
}
|
||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||
const mod = require(serverPath);
|
||||
const config = effectiveConfig(id);
|
||||
// What a module needs to reach the rest of ProdDash — another module's
|
||||
@@ -1117,22 +1060,13 @@ async function installModule(id) {
|
||||
if (download) {
|
||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
const shipped = new Set();
|
||||
for (const [rel, data] of remoteEntry.files) {
|
||||
if (!safeRelPath(rel)) continue;
|
||||
shipped.add(rel);
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
}
|
||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||
// Per-machine executables — a capture tool compiled here, or copied in
|
||||
// by hand on a machine whose toolchain can't build it — are gitignored,
|
||||
// so the repo never ships them. Replacing the folder must not destroy
|
||||
// them: carry executables the download doesn't include over from the
|
||||
// copy being replaced (or from the app's bundled copy on a first
|
||||
// install), or "update" quietly kills a working module.
|
||||
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
|
||||
unmountModule(id);
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staging, dest);
|
||||
@@ -1149,43 +1083,6 @@ async function installModule(id) {
|
||||
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy executable files (any execute bit) that `shipped` does not include
|
||||
* from the first existing source dir into `staging`, keeping their relative
|
||||
* path and mode. Executables only, on purpose: they are the per-machine
|
||||
* build artifacts (gitignored, so never in a download), while stray text
|
||||
* files from an old version — code an update deliberately removed — must
|
||||
* NOT be resurrected.
|
||||
*/
|
||||
function carryOverExecutables(sourceDirs, staging, shipped) {
|
||||
const from = sourceDirs.find((d) => {
|
||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||
});
|
||||
if (!from) return;
|
||||
const walk = (rel) => {
|
||||
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
|
||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
walk(relPath);
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
|
||||
try {
|
||||
const stat = fs.statSync(path.join(from, relPath));
|
||||
if (!(stat.mode & 0o111)) continue;
|
||||
const target = path.join(staging, relPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.join(from, relPath), target);
|
||||
fs.chmodSync(target, stat.mode);
|
||||
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
|
||||
} catch (err) {
|
||||
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('');
|
||||
}
|
||||
|
||||
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||
later reinstall picks them up again. */
|
||||
@@ -1281,18 +1178,9 @@ const DATA_DIR_INSIDE_ROOT = (() => {
|
||||
return rel.split(path.sep).join('/') + '/';
|
||||
})();
|
||||
|
||||
/* A packaged install — the macOS app, the Windows bundle: anything that is
|
||||
not a git checkout, the same test the updater uses to choose the archive
|
||||
over git pull — carries no modules of its own. Every module comes from
|
||||
the catalog into the data directory, and the shell's own update never
|
||||
brings one along. A checkout keeps running the modules in its tree, which
|
||||
is what development wants. */
|
||||
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
|
||||
|
||||
function isKeptOnUpdate(rel) {
|
||||
if (rel.startsWith('.git/')) return true;
|
||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
|
||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||
}
|
||||
|
||||
@@ -1635,29 +1523,6 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Several at once — a fresh install picking its set. One result per id;
|
||||
// a failure stops nothing else.
|
||||
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
|
||||
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
results.push({ ok: true, ...(await installModule(id)) });
|
||||
} catch (err) {
|
||||
results.push({ ok: false, id, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
|
||||
}
|
||||
|
||||
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
|
||||
if (install && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
@@ -2152,7 +2017,6 @@ server.requestTimeout = 0;
|
||||
|
||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
unbundleModules();
|
||||
discoverModules();
|
||||
mountAllModules();
|
||||
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A pretend AbleSet for testing the ableset module without the booth rig.
|
||||
* Speaks AbleSet's OSC API over UDP: answers /subscribe, /getValues and
|
||||
* /unsubscribe, sends /heartbeat every 2 s and pushes value changes while a
|
||||
* setlist plays through — sections, a tempo per song, a section that loops
|
||||
* twice, a song queued out of order, and the time left in song and set.
|
||||
*
|
||||
* node tools/ableset-mock.js [port] (default 39051)
|
||||
* --stopped start with the transport stopped (press nothing; it stays)
|
||||
* --no-live report Ableton Live as not connected
|
||||
* --speed <x> play x times faster than real time (default 1)
|
||||
*
|
||||
* Point the AbleSet module at host 127.0.0.1 and this port.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const { encodeMessage, decodePacket } = require('../modules/ableset/osc');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 39051;
|
||||
const SPEED = Number(argv[argv.indexOf('--speed') + 1]) > 0 && argv.includes('--speed') ? Number(argv[argv.indexOf('--speed') + 1]) : 1;
|
||||
let playing = !argv.includes('--stopped');
|
||||
const live = !argv.includes('--no-live');
|
||||
|
||||
// name, tempo, [section name, bars] …
|
||||
const SONGS = [
|
||||
['1- CALL TO WORSHIP', 72, [['INTRO', 4], ['READING', 12], ['OUTRO', 4]]],
|
||||
['2 -ALLELUIA - A', 125, [['INTRO', 8], ['CHORUS', 8], ['VERSE 1', 16], ['PRE-CHORUS', 8], ['CHORUS', 16], ['BRIDGE', 16], ['CHORUS', 16], ['ENDING', 8]]],
|
||||
['3- WHAT A GOD - A', 76.81, [['INTRO', 4], ['VERSE 1', 16], ['CHORUS', 8], ['VERSE 2', 16], ['CHORUS', 8], ['BRIDGE', 8], ['BRIDGE', 8], ['CHORUS', 16], ['TAG', 4], ['ENDING', 8]]],
|
||||
['4- The Blood - Gb', 68, [['INTRO', 4], ['VERSE 1', 8], ['CHORUS', 8], ['VERSE 2', 8], ['CHORUS', 8], ['OUTRO', 4]]],
|
||||
['LOOP', 100, [['LOOP', 16]]],
|
||||
['5- BE PRAISED- Db', 140, [['INTRO', 8], ['VERSE', 16], ['CHORUS', 16], ['INSTRUMENTAL', 8], ['CHORUS', 16], ['ENDING', 4]], '6/8'],
|
||||
];
|
||||
const COLORS = ['default', 'blue', 'default', 'red', 'default', 'purple'];
|
||||
|
||||
// Lay the songs end to end on one arrangement, as AbleSet does.
|
||||
let cursor = 8;
|
||||
const songs = SONGS.map(([name, tempo, parts, sig], i) => {
|
||||
const [n, d] = (sig || '4/4').split('/').map(Number);
|
||||
const bpb = (n * 4) / d;
|
||||
const start = cursor;
|
||||
const sections = parts.map(([sname, bars]) => {
|
||||
const s = { name: sname, start: cursor, end: cursor + bars * bpb };
|
||||
cursor = s.end;
|
||||
return s;
|
||||
});
|
||||
return { name, tempo, sig: [n, d], start, end: cursor, sections, color: COLORS[i] || 'default' };
|
||||
});
|
||||
const secs = (song, beats) => (beats * 60) / song.tempo;
|
||||
const songDuration = (song) => secs(song, song.end - song.start);
|
||||
|
||||
let songIdx = 1;
|
||||
let beats = songs[songIdx].start + 40; // a little way into the second song
|
||||
let loopsLeft = 0; // section-loop repeats still to play
|
||||
let queued = null; // index of a song queued out of order
|
||||
|
||||
const subscribers = new Map(); // "ip:port" → { ip, port }
|
||||
const sock = dgram.createSocket('udp4');
|
||||
const sent = new Map();
|
||||
|
||||
function values() {
|
||||
const song = songs[songIdx];
|
||||
const sIdx = Math.max(0, song.sections.findIndex((s) => beats >= s.start && beats < s.end));
|
||||
const sect = song.sections[sIdx];
|
||||
const nextSect = song.sections[sIdx + 1] || null;
|
||||
const nextSongIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : -1;
|
||||
const nextSong = songs[nextSongIdx] || null;
|
||||
const leftInSong = secs(song, song.end - beats);
|
||||
const leftInSet = leftInSong + songs.slice(songIdx + 1).reduce((a, s) => a + songDuration(s), 0);
|
||||
const col = (name) => [name, name === 'default' ? '#FFFFFF' : { blue: '#3B82F6', red: '#EF4444', purple: '#A855F7' }[name] || '#FFFFFF'];
|
||||
return {
|
||||
'/global/isConnected': [live ? 1 : 0],
|
||||
'/global/isLoadingFile': [0],
|
||||
'/global/isPlaying': [playing ? 1 : 0],
|
||||
'/global/isRecording': [0],
|
||||
'/global/tempo': [song.tempo],
|
||||
'/global/timeSignature': song.sig,
|
||||
'/global/beatsPosition': [Math.floor(beats)],
|
||||
'/global/finePosition': [beats],
|
||||
'/setlist/name': ['260927 AB (mock)'],
|
||||
'/setlist/songs': songs.map((s) => s.name),
|
||||
'/setlist/songDurations': songs.map(songDuration),
|
||||
'/setlist/songColors': songs.map((s) => s.color),
|
||||
'/setlist/sections': song.sections.map((s) => s.name),
|
||||
'/setlist/sectionColors': song.sections.map((s) => (s.name === 'BRIDGE' ? 'purple' : 'default')),
|
||||
'/setlist/activeSongName': [song.name],
|
||||
'/setlist/activeSongIndex': [songIdx],
|
||||
'/setlist/activeSongStart': [song.start],
|
||||
'/setlist/activeSongEnd': [song.end],
|
||||
'/setlist/activeSongDuration': [songDuration(song)],
|
||||
'/setlist/activeSongColor': col(song.color),
|
||||
'/setlist/activeSectionName': [sect.name],
|
||||
'/setlist/activeSectionIndex': [sIdx],
|
||||
'/setlist/activeSectionStart': [sect.start],
|
||||
'/setlist/activeSectionEnd': [sect.end],
|
||||
'/setlist/activeSectionDuration': [secs(song, sect.end - sect.start)],
|
||||
'/setlist/activeSectionColor': col(sect.name === 'BRIDGE' ? 'purple' : 'default'),
|
||||
'/setlist/activeSectionLoopMode': [loopsLeft > 0 ? 'loop' : null],
|
||||
'/setlist/activeSectionLoopCount': [loopsLeft],
|
||||
'/setlist/isInActiveSectionLoop': [loopsLeft > 0 ? 1 : 0],
|
||||
'/setlist/nextSectionName': [nextSect ? nextSect.name : null],
|
||||
'/setlist/nextSectionIndex': [nextSect ? sIdx + 1 : -1],
|
||||
'/setlist/nextSectionStart': [nextSect ? nextSect.start : null],
|
||||
'/setlist/nextSectionEnd': [nextSect ? nextSect.end : null],
|
||||
'/setlist/nextSongName': [nextSong ? nextSong.name : null],
|
||||
'/setlist/nextSongIndex': [nextSongIdx],
|
||||
'/setlist/nextSongDuration': [nextSong ? songDuration(nextSong) : null],
|
||||
'/setlist/nextSongColor': col(nextSong ? nextSong.color : 'default'),
|
||||
'/setlist/queuedName': [queued != null ? songs[queued].name : null, null],
|
||||
'/setlist/queuedIndex': [queued != null ? queued : -1, -1],
|
||||
'/setlist/queuedType': [queued != null ? 'song' : null],
|
||||
'/setlist/loopEnabled': [0],
|
||||
'/setlist/isCountingIn': [0],
|
||||
'/setlist/remainingTimeInSong': [Math.round(leftInSong)],
|
||||
'/setlist/remainingTimeInSet': [Math.round(leftInSet)],
|
||||
'/audioInterfaces/connected': [1],
|
||||
'/audioInterfaces/count': [1],
|
||||
'/audioInterfaces/names': ['PlayAUDIO12'],
|
||||
'/audioInterfaces/status': ['sceneA'],
|
||||
'/version/app': ['mock'],
|
||||
};
|
||||
}
|
||||
|
||||
function sendTo(sub, address, args) {
|
||||
sock.send(encodeMessage(address, args), sub.port, sub.ip); // null goes out as nil, as AbleSet sends it
|
||||
}
|
||||
|
||||
function push(force = false) {
|
||||
const now = values();
|
||||
for (const [address, args] of Object.entries(now)) {
|
||||
const key = JSON.stringify(args);
|
||||
if (!force && sent.get(address) === key && address !== '/global/finePosition') continue;
|
||||
if (address === '/global/finePosition' && !playing && !force) continue;
|
||||
sent.set(address, key);
|
||||
for (const sub of subscribers.values()) sendTo(sub, address, args);
|
||||
}
|
||||
}
|
||||
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
let msgs;
|
||||
try { msgs = decodePacket(buf); } catch { return; }
|
||||
for (const { address, args } of msgs) {
|
||||
const ip = args[0] === 'auto' || !args[0] ? rinfo.address : String(args[0]);
|
||||
const port = Number(args[1]) || rinfo.port;
|
||||
if (address === '/subscribe') {
|
||||
subscribers.set(`${ip}:${port}`, { ip, port });
|
||||
sendTo({ ip, port }, '/subscribed', []);
|
||||
console.log(`subscribed ${ip}:${port} (${args[2] || 'no name'})`);
|
||||
} else if (address === '/unsubscribe') {
|
||||
subscribers.delete(`${ip}:${port}`);
|
||||
console.log(`unsubscribed ${ip}:${port}`);
|
||||
} else if (address === '/getValues') {
|
||||
const sub = { ip, port };
|
||||
for (const [a, v] of Object.entries(values())) sendTo(sub, a, v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
for (const sub of subscribers.values()) sendTo(sub, '/heartbeat', []);
|
||||
}, 2000);
|
||||
|
||||
let last = Date.now();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const dtSec = ((now - last) / 1000) * SPEED;
|
||||
last = now;
|
||||
if (playing) {
|
||||
const song = songs[songIdx];
|
||||
const before = song.sections.find((s) => beats >= s.start && beats < s.end);
|
||||
beats += dtSec * (song.tempo / 60);
|
||||
// The second BRIDGE of song 3 loops twice before it lets go.
|
||||
if (before && beats >= before.end && loopsLeft > 0) {
|
||||
loopsLeft -= 1;
|
||||
beats = before.start + (beats - before.end);
|
||||
}
|
||||
const after = song.sections.find((s) => beats >= s.start && beats < s.end);
|
||||
if (after && after !== before && after.name === 'BRIDGE' && song.sections.indexOf(after) === 6) loopsLeft = 2;
|
||||
if (beats >= song.end) {
|
||||
// Stop at the end of each song, as a service does, then carry on.
|
||||
const nextIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : 0;
|
||||
queued = null;
|
||||
songIdx = nextIdx;
|
||||
beats = songs[songIdx].start;
|
||||
playing = false;
|
||||
push();
|
||||
setTimeout(() => { playing = true; last = Date.now(); push(); }, 4000 / SPEED);
|
||||
// Halfway through the set someone queues the last song early.
|
||||
if (songIdx === 3) setTimeout(() => { queued = 5; push(); }, 8000 / SPEED);
|
||||
}
|
||||
}
|
||||
push();
|
||||
}, 100);
|
||||
|
||||
sock.bind(PORT, () => console.log(`AbleSet mock on udp ${PORT}${playing ? '' : ' (stopped)'}${live ? '' : ' (Live not connected)'}${SPEED !== 1 ? ` at ${SPEED}×` : ''}`));
|
||||
@@ -11,8 +11,6 @@
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* GET /services/v2/service_types/{id}
|
||||
* GET /services/v2/service_types/{id}/teams every team, including one
|
||||
* nobody is scheduled on
|
||||
* GET /services/v2/service_types/{id}/item_note_categories
|
||||
* the note categories the
|
||||
* items below use, plus one
|
||||
@@ -27,15 +25,6 @@
|
||||
* "Starts in" state is visible
|
||||
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||
* ItemNote compound documents
|
||||
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
|
||||
* records + Team / Person, with
|
||||
* confirmed / unconfirmed /
|
||||
* declined statuses, a team
|
||||
* nobody is on, and people with
|
||||
* and without a photo
|
||||
* GET /avatars/{personId}.svg those photos (initials on a
|
||||
* disc) — the module proxies
|
||||
* them like the real CDN
|
||||
*
|
||||
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
||||
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||
@@ -205,107 +194,6 @@ function itemsDocument(planId, include) {
|
||||
|
||||
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Who is scheduled, as Planning Center returns it from
|
||||
* /plans/{id}/team_members?include=team,person: teams in their own order,
|
||||
* people with a position and a scheduling status ("C" confirmed,
|
||||
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
|
||||
* has no picture for — the tile draws their initials instead.
|
||||
*/
|
||||
const TEAMS = [
|
||||
{
|
||||
id: 't1', name: 'Worship', sequence: 1, members: [
|
||||
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
|
||||
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
|
||||
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
|
||||
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
|
||||
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
|
||||
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
|
||||
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't2', name: 'Production', sequence: 2, members: [
|
||||
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
|
||||
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
|
||||
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
|
||||
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
|
||||
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't3', name: 'Hosting', sequence: 3, members: [
|
||||
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
|
||||
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
|
||||
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
|
||||
],
|
||||
},
|
||||
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
|
||||
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
|
||||
];
|
||||
|
||||
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
|
||||
function avatarSvg(name) {
|
||||
const words = String(name).trim().split(/\s+/);
|
||||
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
|
||||
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
|
||||
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
|
||||
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
|
||||
}
|
||||
|
||||
function teamMembersDocument(planId, include, port) {
|
||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
const data = [];
|
||||
const included = [];
|
||||
const seenTeams = new Set();
|
||||
for (const team of TEAMS) {
|
||||
for (const m of team.members) {
|
||||
data.push({
|
||||
type: 'PlanPerson',
|
||||
id: m.id,
|
||||
attributes: {
|
||||
name: m.name,
|
||||
status: m.status,
|
||||
team_position_name: m.position,
|
||||
notes: m.notes || null,
|
||||
decline_reason: m.decline || null,
|
||||
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
||||
status_updated_at: iso(bootedAt - 3600000),
|
||||
},
|
||||
relationships: {
|
||||
plan: { data: { type: 'Plan', id: planId } },
|
||||
team: { data: { type: 'Team', id: team.id } },
|
||||
person: { data: { type: 'Person', id: m.person } },
|
||||
},
|
||||
});
|
||||
if (wants.has('person')) {
|
||||
included.push({
|
||||
type: 'Person',
|
||||
id: m.person,
|
||||
attributes: {
|
||||
name: m.name,
|
||||
first_name: m.name.split(' ')[0],
|
||||
last_name: m.name.split(' ').slice(-1)[0],
|
||||
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (wants.has('team') && !seenTeams.has(team.id)) {
|
||||
seenTeams.add(team.id);
|
||||
included.push({
|
||||
type: 'Team',
|
||||
id: team.id,
|
||||
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, included };
|
||||
}
|
||||
|
||||
function folderRecord(f) {
|
||||
return {
|
||||
type: 'Folder',
|
||||
@@ -354,16 +242,6 @@ const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
||||
|
||||
// Photos are plain images on Planning Center's CDN — no auth there, none here.
|
||||
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
|
||||
if (avatar) {
|
||||
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
|
||||
if (!person) return notFound(res);
|
||||
const body = avatarSvg(person.name);
|
||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
|
||||
return void res.end(body);
|
||||
}
|
||||
|
||||
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
||||
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
||||
}
|
||||
@@ -388,13 +266,6 @@ const server = http.createServer((req, res) => {
|
||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||
if (!st) return notFound(res);
|
||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||
if (parts[2] === 'teams' && parts.length === 3) {
|
||||
return collection(res, TEAMS.map((t) => ({
|
||||
type: 'Team', id: t.id,
|
||||
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
|
||||
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
|
||||
})), url);
|
||||
}
|
||||
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
||||
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
||||
}
|
||||
@@ -415,11 +286,6 @@ const server = http.createServer((req, res) => {
|
||||
if (!plan) return notFound(res);
|
||||
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
||||
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
||||
if (parts[4] === 'team_members') {
|
||||
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
return send(res, 200, doc);
|
||||
}
|
||||
if (parts[4] === 'items') {
|
||||
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
|
||||
@@ -152,39 +152,10 @@ const server = http.createServer((req, res) => {
|
||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
||||
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
||||
|
||||
sendJson(res, { error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
||||
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
||||
60–75 s the same bumper paused at 1:00
|
||||
75–90 s playing again
|
||||
90–120 s nothing loaded
|
||||
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
||||
must infer looping from the position jumping back.
|
||||
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
||||
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
||||
function transport() {
|
||||
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
||||
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
||||
if (phase < 90) {
|
||||
const playing = phase < 60 || phase >= 75;
|
||||
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
||||
return {
|
||||
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
||||
time: Math.round(pos * 10) / 10,
|
||||
};
|
||||
}
|
||||
if (phase < 120) return none;
|
||||
return {
|
||||
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
||||
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||
|
||||
const cryptoMod = require('crypto');
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A packaged ProdDash carries no modules. This drives a packaged copy of the
|
||||
* shell — server.js and its client in a folder with no .git — through the
|
||||
* three things that make that true:
|
||||
*
|
||||
* 1. an install made before 1.10.0 still has modules in its app folder:
|
||||
* at boot they move into the data directory once, executables keeping
|
||||
* their mode, an uninstalled one is dropped, and the app folder's
|
||||
* modules/ is gone;
|
||||
* 2. a shell update from an archive that carries modules/ never writes
|
||||
* them into the app folder — they show up in the catalog instead;
|
||||
* 3. a fresh install lists everything the repo offers, and installing
|
||||
* several at once lands them in the data directory and mounts them.
|
||||
*
|
||||
* node tools/unbundled.test.js
|
||||
*
|
||||
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
|
||||
* directory. Exits non-zero on the first failure and cleans up after itself.
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO = path.join(__dirname, '..');
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const { port } = probe.address();
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
probe.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(url, ms = 15000) {
|
||||
const deadline = Date.now() + ms;
|
||||
let last = '';
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.ok) return;
|
||||
last = 'HTTP ' + r.status;
|
||||
} catch (err) {
|
||||
last = err.message;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error('server did not come up: ' + last);
|
||||
}
|
||||
|
||||
function writeModule(dir, id, version, extra = {}) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
|
||||
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
|
||||
}
|
||||
|
||||
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
|
||||
function buildArchive(dir) {
|
||||
const top = path.join(dir, 'ProdDash-main');
|
||||
fs.mkdirSync(top, { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||||
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||||
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||||
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||||
const out = path.join(dir, 'main.tar.gz');
|
||||
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||||
return fs.readFileSync(out);
|
||||
}
|
||||
|
||||
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
|
||||
function buildPackagedApp(app) {
|
||||
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
|
||||
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
|
||||
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
|
||||
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
|
||||
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
|
||||
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
|
||||
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
|
||||
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
|
||||
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
|
||||
const app = path.join(work, 'app');
|
||||
const dataDir = path.join(work, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
buildPackagedApp(app);
|
||||
// The operator had uninstalled "gone" before this update.
|
||||
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
|
||||
const archive = buildArchive(path.join(work, 'forge'));
|
||||
|
||||
const forge = http.createServer((req, res) => {
|
||||
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||||
return res.end(archive);
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
const forgePort = await freePort();
|
||||
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||||
|
||||
const port = await freePort();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const env = {
|
||||
...process.env,
|
||||
PRODDASH_DATA_DIR: dataDir,
|
||||
PORT: String(port),
|
||||
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
|
||||
PRODDASH_REPO: 'michael/ProdDash',
|
||||
PRODDASH_NO_REMOTE_CHECK: '1',
|
||||
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
|
||||
};
|
||||
let log = '';
|
||||
let child = null;
|
||||
const start = () => {
|
||||
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout.on('data', (d) => { log += d; });
|
||||
child.stderr.on('data', (d) => { log += d; });
|
||||
return child;
|
||||
};
|
||||
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
|
||||
const api = async (p, init = {}) => {
|
||||
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
};
|
||||
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
// ── 1. the move ──
|
||||
start();
|
||||
await waitFor(base + '/api/theme');
|
||||
const moved = path.join(dataDir, 'modules', 'demo');
|
||||
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
|
||||
const helper = path.join(moved, 'bin', 'demo-helper');
|
||||
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
|
||||
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
|
||||
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
|
||||
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
|
||||
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
|
||||
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
|
||||
assert.match(log, /the app folder carries no modules now/);
|
||||
const state = (await api('/api/admin/state')).body;
|
||||
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
|
||||
|
||||
// The catalog sees the repo's modules/ — as offers, not as a bundle.
|
||||
let cat = (await api('/api/admin/catalog?refresh=1')).body;
|
||||
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
|
||||
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
|
||||
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
|
||||
|
||||
// ── 2. a shell update carries no modules ──
|
||||
const running = child;
|
||||
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||||
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
|
||||
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
|
||||
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
|
||||
|
||||
log = '';
|
||||
start();
|
||||
await waitFor(base + '/api/theme');
|
||||
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
|
||||
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
|
||||
cat = (await api('/api/admin/catalog')).body;
|
||||
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
|
||||
|
||||
// ── 3. several at once ──
|
||||
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
|
||||
assert.equal(many.status, 200);
|
||||
assert.equal(many.body.ok, false, 'one of them does not exist');
|
||||
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
|
||||
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
|
||||
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
|
||||
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||||
assert.equal(empty.status, 400, 'nothing to install is said');
|
||||
|
||||
console.log('unbundled shell: every check passed');
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error('FAIL:', err.message);
|
||||
console.error('--- last of the server log ---\n' + log.slice(-2500));
|
||||
} finally {
|
||||
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
||||
forge.close();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
Reference in New Issue
Block a user