diff --git a/CLAUDE.md b/CLAUDE.md index 6d45a83..572c2c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,8 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in +`/admin`, modules in `modules/` (the repo's copies — a packaged install ships +none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries the server and its own Node runtime, supervises them, and owns the macOS permissions modules declare; see its README). Runtime state (module @@ -28,7 +29,12 @@ title-bar controls). The rules that matter most: ship inside the module folder** (zero deps is the norm). **Everything the module does runs inside its server entry** — no helper scripts, companion programs or daemons the operator starts by hand; a listener or decoder is - started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"` + started in `init()` and stopped in `stop()`. **A native helper ships + prebuilt inside the module folder for Apple silicon macOS and 64-bit + Windows** (`bin/-macos-arm64`, `bin/-win-x64.exe`, source and + build script under `native/`); nothing is ever compiled on the machine + running ProdDash, and a missing binary means the feature reports itself + off, not a broken module. Declare `"proddash": ">=x.y.z"` and bump the module's `version` on every change. - Browsers never call upstream services directly; the module's server routes proxy everything (no CORS, secrets stay server-side). @@ -45,6 +51,7 @@ 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) diff --git a/README.md b/README.md index b36d66f..eda564d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ Download the disk image for your Mac — `ProdDash--arm64.dmg` for Apple silicon, `ProdDash--x86_64.dmg` for Intel — open it and drag **ProdDash** to Applications. That's the whole installation: the app carries the dashboard and its own Node runtime, so -nothing else has to be installed and nothing can be missing. It lives in the +nothing else has to be installed. It carries no modules — open **Admin → +Available modules**, tick the ones this machine needs and press **Install +selected**; each updates on its own from then on. It lives in the menu bar, starts and stops the server, can open at login, and holds the permissions the modules need — the microphone the LTC listener reads, and local network access for ProdCom and ProPresenter. macOS grants those to the @@ -203,13 +205,15 @@ below. Nothing is installed without someone clicking. - **Available modules** lists modules in the repo that aren't installed here. **Install** downloads the module into the data directory's `modules/` and starts it right away — nothing is written into the app folder, and no - restart is needed. Modules that ship with your copy of ProdDash are - "bundled"; ones you installed are "installed"; if both exist, the newer - version runs. + restart is needed. Tick several and press **Install selected** to fetch + them in one go. A packaged ProdDash (the app, the Windows bundle) ships + with no modules at all: this list is how every one of them arrives, and a + ProdDash update never brings one along. A git checkout also runs the + modules in its own tree ("bundled"); if both exist, the newer version runs. - **Uninstall** on a module card removes an installed module's files. For a - bundled module (its folder belongs to the app) it hides the module from - every dashboard instead; it reappears under Available modules and can be - brought back without downloading. Settings are kept either way. + checkout's bundled module (its folder belongs to the repo) it hides the + module from every dashboard instead; it reappears under Available modules + and can be brought back without downloading. Settings are kept either way. - **Module updates**: when the repo has a newer version of an installed module, its card shows **Update to vX**. - **Version requirements**: every module declares the ProdDash versions it @@ -327,9 +331,11 @@ 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) and LTC timecode as auto-sized cards. The + picker lists every - discovered timer (and the LTC card) as its own tile, or add "All timers" - and pick a selection via the tile's "Timers ▾" menu; overruns are + elapsed), the **video countdown** (time left on what ProPresenter is + playing, with a per-tile "Looping videos" switch so looping backgrounds + stay hidden) and LTC timecode as auto-sized cards. The + picker lists + every discovered timer (and the LTC card) as its own tile, or add "All + timers" and pick a selection via the tile's "Timers ▾" menu; overruns are unmistakably red. Read-only — no start/stop controls. Admin config is in three groups: **ProPresenter** (endpoint host : port, API password), **LTC Timecode** (the listener switch, and — when it's on — the audio @@ -347,8 +353,9 @@ 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 two tiles in the + picker: - **Plan** (the order of service) and **Teams** (who is scheduled). +- **PCO Plan** — Planning Center Services, as three tiles in the + picker: + **Plan** (the order of service), **Teams** (who is scheduled) and **Song + Key** (the key of the item on screen). **Plan** shows every plan item with its scheduled start time, length, type, song key, @@ -370,6 +377,25 @@ below. Nothing is installed without someone clicking. `{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 diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index e0d9200..e41c2f5 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -28,7 +28,10 @@ people running the booth depend on them. ProdDash. If your feature needs a listener, a decoder or a worker, your server entry owns it: start it in `init()`, stop it in `stop()`, report it in `health()`. A child process the module itself spawns and supervises - is fine; a program someone launches by hand is not. + is fine; a program someone launches by hand is not. **A native helper — + anything that is not JavaScript — ships prebuilt inside the module folder, + for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on + the machine running ProdDash** (see "Native helpers" below). 4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the manifest) and **bump your own `version`** whenever you change the module. The admin page refuses to install a module whose requirement the running @@ -83,19 +86,86 @@ modules// ├── 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) +│ ├── -macos-arm64 +│ └── -win-x64.exe +├── native/ their source and the script that builds every bin/ file └── ... assets (css, images…) served statically at /modules// ``` **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 (`/modules//`) and starts -immediately. Developing locally? Drop the folder into `modules/` (bundled -with the checkout) or into the data directory's `modules/` and restart. No -shell code changes, ever. Modules bundled with a checkout and modules -installed from the admin page live side by side; when both have the same id -the newer version runs. **Uninstall** in the admin page removes an installed -module's files (a bundled one is hidden instead, since its folder belongs to -the app); its settings are kept for a later reinstall. +immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships +with **no modules at all**: every one of them arrives this way, and the +shell's own update never brings one along, so what is in git is exactly what +runs on a booth. Developing locally? A git checkout runs whatever is in its +own `modules/` folder (the repo's copies, "bundled"), or drop a folder into +the data directory's `modules/` and restart. No shell code changes, ever. In +a checkout the two live side by side; when both have the same id the newer +version runs. **Uninstall** in the admin page removes an installed module's +files (in a checkout, the tree's copy is hidden instead, since that folder +belongs to the repo); its settings are kept for a later reinstall. + +### Native helpers + +Some features need a program JavaScript cannot be: reading an audio input, +decoding timecode, measuring sound. Rule 3 says the module owns that program; +this section says how it ships: **already built, for every platform it runs +on, inside the module folder.** The machine running ProdDash is a packaged +app or the Windows bundle plus modules from the admin page's catalog. It has +no compiler and never will — the catalog delivers exactly the folder that is +in the repo, so the binaries live in git next to the code. + +- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel + macOS is welcome but optional. One file per platform in `bin/`, named for + what it is: + + | Platform | File | + | --- | --- | + | macOS, Apple silicon | `bin/-macos-arm64` | + | Windows, 64-bit | `bin/-win-x64.exe` | + | macOS, Intel (optional) | `bin/-macos-x86_64` | + +- **Pick it at runtime, and cope without it.** Choose by `process.platform` + and `process.arch`; when there is no file for this machine, the feature + says so — in `health()` and in the tile's `setStatus` — and everything else + in the module keeps working. Never try to build it, never look for a copy + elsewhere on the machine, never tell the operator to install anything. + + ```js + const HELPERS = { + darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' }, + win32: { x64: 'ltc-capture-win-x64.exe' }, + }; + const name = HELPERS[process.platform]?.[process.arch]; + const helper = name ? path.join(__dirname, 'bin', name) : ''; + if (!helper || !fs.existsSync(helper)) { + // Timecode is off on this platform. Say it once, keep the clock running. + } + ``` + +- **One contract, any language.** Every platform's binary speaks the same + command line and output format, so `server.js` has one code path. What is + behind it is your choice per platform — Swift against CoreAudio on macOS, + C#, C or Rust on Windows — the module does not care. +- **Source and build beside it.** Keep the source under `native/` with one + script that produces every `bin/` file from a checkout, so any binary can be + rebuilt from what is committed. A helper that cannot be rebuilt does not + ship. +- **Bump the module version whenever a binary changes**, like any other + file — that is how installs learn about it. +- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at + most: the catalog fetches the whole repo as one archive, capped at 80 MB. +- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary + — Developer ID when you have it, ad-hoc at least. Windows needs nothing: + files the app writes carry no download mark, so SmartScreen never sees + them. Permissions come from the launcher (see "Permissions macOS has to + grant"), not from the binary. + +A helper that ends up existing for one platform only is an exception made +on purpose, not a default: say so in the module's README and in the health +line, so a Windows booth knows why the feature is off. ## module.json @@ -684,6 +754,7 @@ 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. | @@ -811,6 +882,11 @@ client-side failures (a tile that fails to start says so in the tile). Application ID + Secret under PCO Plan in `/admin`, save, then pick "Sunday Service" from the Service type list. Its item titles match `pp-mock.js`, so running both mocks exercises the ProPresenter matching. +- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through + over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a + section that loops twice, a song queued out of order, heartbeats. + `--stopped`, `--no-live` and `--speed 4` exercise the other states. Point + the AbleSet module at host `127.0.0.1`, port `39071`. **The resilience checklist** — every module must pass this before it's done: diff --git a/launcher/macos/README.md b/launcher/macos/README.md index d8a0f16..e97c9eb 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -69,9 +69,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit against nodejs.org's published checksums either way. The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon -is the app's icon. A module's native helper is compiled during the build -(`.swift` beside a module's files becomes ``), so the Mac running -ProdDash never needs a compiler. +is the app's icon. The app carries no modules: they are installed from the +admin page's catalog into the data directory, and a module's native helper +ships prebuilt inside it (see the module guide), so the Mac running ProdDash +never needs a compiler. ## Signing diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh index 8b0b219..e339d73 100755 --- a/launcher/macos/build.sh +++ b/launcher/macos/build.sh @@ -1,6 +1,8 @@ #!/bin/zsh # Build ProdDash.app — the whole application: launcher, server and its own -# Node runtime, with nothing to install first. +# Node runtime, with nothing to install first. It carries no modules: those +# come from the admin page's catalog into the data directory, and a module's +# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md). # # ./build.sh build/ProdDash.app, self-contained # ./build.sh --dmg …and build/ProdDash--arm64.dmg @@ -134,23 +136,14 @@ if [[ $DEV -eq 0 ]]; then tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ "node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node" - echo " embedding ProdDash $VERSION" + echo " embedding ProdDash $VERSION (no modules — the catalog installs them)" mkdir -p "$PAYLOAD/config" cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/" - cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/" + cp -R "$REPO_ROOT/public" "$PAYLOAD/" # Only the checked-in defaults: config/ also holds this machine's own # modules.json and layouts when ProdDash has ever run from the checkout. cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/" find "$PAYLOAD" -name '.DS_Store' -delete - - # A module's native helper is built here, so the Mac running ProdDash needs - # no compiler: .swift beside a module's files becomes . - 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 ────────────────────────────────────────────────────────────── @@ -172,16 +165,6 @@ fi if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node" fi -if [[ $DEV -eq 0 ]]; then - for helper in "$PAYLOAD"/modules/*/*; do - # A compiled helper is the extensionless executable beside a module's - # sources — test the name, not the path, which has dots all over it. - name=$(basename "$helper") - [[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue - echo " signing $name" - codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper" - done -fi codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP" codesign --verify --deep --strict "$APP" && echo " signature verifies" diff --git a/modules/ableset/client.js b/modules/ableset/client.js new file mode 100644 index 0000000..c770819 --- /dev/null +++ b/modules/ableset/client.js @@ -0,0 +1,727 @@ +/* AbleSet — client part. + + One factory draws every AbleSet tile; `moduleApi.variant` says which one + (the server's tiles() list). All of them read the same stream: `state` + whenever something changes, `pos` five times a second while playing. + Between packets the playhead and the countdowns are extrapolated from the + tempo and the clock, so bars glide instead of stepping. */ + +const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status']; + +/* AbleSet names its colours after Live's palette; these are close enough to + read as the same colour on a dark tile. Unknown names draw no colour. */ +const NAMED_COLORS = { + red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399', + mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6', + indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6', + rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5', +}; + +const ICON_STRIP = ''; +const ICON_WHEEL = ''; + +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 = `
`; + const shell = root.firstElementChild; + const body = shell.querySelector('.as-body'); + const offlineEl = shell.querySelector('.as-offline'); + const $ = (sel) => body.querySelector(sel); + + const BAR = `
`; + + const SKELETONS = { + now: ` +
+
+ +
+ ${BAR} +
+
Next:
+
+
+
`, + sections: ` +
+
`, + setlist: ` +
+
+
`, + transport: ` +
+
`, + tempo: ` +
BPM
+
+
`, + 'song-time': ` +
+
+
+ ${BAR}`, + 'set-time': ` +
Set
+
+
`, + section: ` +
+
+
`, + 'next-song': ` +
Next
+
+
+ `, + status: `
`, + }; + 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) => `
${esc(x.name)}
`).join('') + || '
No sections
'; + 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 `
+ + ${c ? '' : ''} + ${esc(x.name)} + ${on('showDurations') ? `${x.duration != null ? fmt(x.duration) : ''}` : ''} +
`; + }).join('') || '
No setlist
'; + } + 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) => ``).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]) => `
${esc(k)}${esc(v)}
`).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 = ''; + }, + }; +} diff --git a/modules/ableset/module.json b/modules/ableset/module.json new file mode 100644 index 0000000..f6eb6d4 --- /dev/null +++ b/modules/ableset/module.json @@ -0,0 +1,36 @@ +{ + "id": "ableset", + "name": "AbleSet", + "version": "1.0.1", + "proddash": ">=1.5.0", + "description": "The song AbleSet is playing — tempo, time left, sections, setlist and status", + "permissions": [ + { + "kind": "local-network", + "reason": "Following AbleSet on this network" + } + ], + "provides": ["timers"], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 1, + "h": 1 + }, + "defaultSize": { + "w": 8, + "h": 4 + }, + "configSchema": { + "ableset": { + "type": "endpoint", + "label": "AbleSet", + "default": { + "host": "", + "port": 39051 + }, + "help": "The Mac running AbleSet (e.g. AB-Tracks.local). 39051 is AbleSet's OSC port; it only changes if someone changed it in AbleSet's settings." + } + } +} diff --git a/modules/ableset/osc.js b/modules/ableset/osc.js new file mode 100644 index 0000000..3a37d06 --- /dev/null +++ b/modules/ableset/osc.js @@ -0,0 +1,101 @@ +'use strict'; + +/** + * The slice of OSC 1.0 AbleSet speaks — messages and bundles with int32, + * float32, string, blob, true/false/nil — encoded and decoded with Buffer + * alone, so the module needs nothing outside Node. + */ + +function pad4(n) { + return (n + 3) & ~3; +} + +function encodeString(s) { + const raw = Buffer.from(String(s), 'utf8'); + const out = Buffer.alloc(pad4(raw.length + 1)); + raw.copy(out); + return out; +} + +/** + * One message. Arguments are typed by their JS value: integers are int32, + * other numbers float32, booleans T/F, null nil, everything else a string. + */ +function encodeMessage(address, args = []) { + let tags = ','; + const parts = []; + for (const a of args) { + if (a === null || a === undefined) { + tags += 'N'; + } else if (typeof a === 'boolean') { + tags += a ? 'T' : 'F'; + } else if (typeof a === 'number' && Number.isInteger(a)) { + tags += 'i'; + const b = Buffer.alloc(4); + b.writeInt32BE(a); + parts.push(b); + } else if (typeof a === 'number') { + tags += 'f'; + const b = Buffer.alloc(4); + b.writeFloatBE(a); + parts.push(b); + } else { + tags += 's'; + parts.push(encodeString(a)); + } + } + return Buffer.concat([encodeString(address), encodeString(tags), ...parts]); +} + +function readString(buf, at) { + let end = at; + while (end < buf.length && buf[end] !== 0) end += 1; + if (end >= buf.length) throw new Error('unterminated OSC string'); + return { value: buf.toString('utf8', at, end), next: pad4(end + 1) }; +} + +function decodeMessage(buf, out) { + const addr = readString(buf, 0); + let at = addr.next; + const args = []; + if (at < buf.length && buf[at] === 0x2c) { + const tagStr = readString(buf, at); + at = tagStr.next; + for (const t of tagStr.value.slice(1)) { + switch (t) { + case 'i': args.push(buf.readInt32BE(at)); at += 4; break; + case 'f': args.push(buf.readFloatBE(at)); at += 4; break; + case 'd': args.push(buf.readDoubleBE(at)); at += 8; break; + case 'h': args.push(Number(buf.readBigInt64BE(at))); at += 8; break; + case 's': case 'S': { const s = readString(buf, at); args.push(s.value); at = s.next; break; } + case 'b': { const n = buf.readInt32BE(at); args.push(buf.subarray(at + 4, at + 4 + n)); at = pad4(at + 4 + n); break; } + case 'T': args.push(true); break; + case 'F': args.push(false); break; + case 'N': args.push(null); break; + case 'I': args.push(Infinity); break; + case 't': at += 8; break; + default: throw new Error(`unsupported OSC type tag "${t}"`); + } + } + } + out.push({ address: addr.value, args }); +} + +/** Every message in a packet, bundles flattened, in order. */ +function decodePacket(buf, out = []) { + if (buf.length >= 16 && buf.toString('ascii', 0, 8) === '#bundle\0') { + let at = 16; // "#bundle\0" + 8-byte timetag + while (at + 4 <= buf.length) { + const n = buf.readInt32BE(at); + at += 4; + if (n <= 0 || at + n > buf.length) break; + decodePacket(buf.subarray(at, at + n), out); + at += n; + } + return out; + } + decodeMessage(buf, out); + return out; +} + +module.exports = { encodeMessage, decodePacket }; diff --git a/modules/ableset/server.js b/modules/ableset/server.js new file mode 100644 index 0000000..ce6262b --- /dev/null +++ b/modules/ableset/server.js @@ -0,0 +1,719 @@ +'use strict'; + +/** + * AbleSet — server part. + * + * Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet + * listens on 39051). `/subscribe auto ` 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`); + }, + }; + }, +}; diff --git a/modules/ableset/style.css b/modules/ableset/style.css new file mode 100644 index 0000000..d93e7f0 --- /dev/null +++ b/modules/ableset/style.css @@ -0,0 +1,416 @@ +/* AbleSet tiles. Every size hangs off the tile itself (container units), so + a tile reads the same at 2×1 on a laptop as at 12×6 on a wall. Colours are + the shell's variables; a tone (ok / warn / alert / muted) picks among them, + and AbleSet's own song or section colour arrives as --song when a tile is + set to use it. */ + +[data-module="ableset"] .as-root { + --tone: var(--text); + --live: var(--accent); + position: absolute; + inset: 0; + container-type: size; + overflow: hidden; + user-select: none; + -webkit-user-select: none; + color: var(--text); + font-variant-numeric: tabular-nums; +} +[data-module="ableset"] .as-root.has-song-color { --live: var(--song); } +[data-module="ableset"] .as-root[data-tone="ok"] { --tone: var(--accent); } +[data-module="ableset"] .as-root[data-tone="warn"] { --tone: var(--warn); } +[data-module="ableset"] .as-root[data-tone="alert"] { --tone: var(--danger); } +[data-module="ableset"] .as-root[data-tone="muted"] { --tone: var(--muted); } + +[data-module="ableset"] .as-body { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + padding: clamp(4px, 4cqmin, 28px); + gap: clamp(2px, 2.5cqh, 18px); + min-width: 0; +} +[data-module="ableset"] [hidden] { display: none !important; } + +[data-module="ableset"] .as-offline { + position: absolute; + top: clamp(3px, 3cqmin, 14px); + right: clamp(4px, 4cqmin, 18px); + z-index: 2; + font-size: clamp(9px, 6cqmin, 15px); + font-weight: 700; + color: var(--danger); + text-transform: uppercase; + letter-spacing: 0.04em; +} +[data-module="ableset"] .as-root.is-offline .as-body { opacity: 0.35; filter: grayscale(0.7); } + +[data-module="ableset"] [data-fit] { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* ── the song bar: played sections · this section · the playhead ── */ + +[data-module="ableset"] .as-bar { + --done: 0%; + --sect-l: 0%; + --sect-w: 0%; + --played: 0%; + position: relative; + flex-shrink: 0; + height: clamp(4px, 5.5cqh, 26px); + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--live) 14%, var(--panel)); +} +[data-module="ableset"] .as-bar-done { + position: absolute; + inset: 0 auto 0 0; + width: var(--done); + background: color-mix(in srgb, var(--live) 32%, var(--panel)); +} +[data-module="ableset"] .as-bar-sect { + position: absolute; + top: 0; + bottom: 0; + left: var(--sect-l); + width: var(--sect-w); + background: color-mix(in srgb, var(--live) 55%, var(--panel)); + overflow: hidden; +} +[data-module="ableset"] .as-bar-played { + position: absolute; + inset: 0 auto 0 0; + width: var(--played); + background: linear-gradient(90deg, + color-mix(in srgb, var(--live) 70%, var(--panel)) 0%, + color-mix(in srgb, var(--live) 70%, var(--panel)) calc(100% - 3.5cqw), + color-mix(in srgb, var(--live) 70%, var(--text)) 100%); +} + +/* ── NOW PLAYING ─────────────────────────────────────────────────── */ + +[data-module="ableset"] .as-now .as-body { + align-items: center; + padding-inline: clamp(6px, 7cqw, 90px); + gap: clamp(3px, 4cqh, 30px); +} +[data-module="ableset"] .as-now .as-title-box, +[data-module="ableset"] .as-now .as-next-box { width: 100%; display: flex; justify-content: center; min-width: 0; } +[data-module="ableset"] .as-now .as-title { + font-size: clamp(14px, 20cqh, 170px); + font-weight: 800; + line-height: 1.05; + letter-spacing: -0.01em; + color: var(--text); + transition: color 0.3s; +} +[data-module="ableset"] .as-now.is-playing .as-title { color: var(--live); } +[data-module="ableset"] .as-now .as-bar { width: 100%; } + +[data-module="ableset"] .as-badges { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: clamp(3px, 1.8cqh, 16px); + max-width: 100%; +} +[data-module="ableset"] .as-badge { + font-size: clamp(9px, 7cqh, 44px); + font-weight: 700; + line-height: 1; + padding: 0.28em 0.5em; + border-radius: 0.22em; + background: color-mix(in srgb, var(--text) 12%, var(--panel)); + color: color-mix(in srgb, var(--text) 80%, var(--accent)); + white-space: nowrap; +} +[data-module="ableset"] .as-badge.b-section { + color: var(--text); + text-transform: uppercase; + background: color-mix(in srgb, var(--text) 7%, var(--panel)); +} +[data-module="ableset"] .as-badge.b-clock[data-tone="warn"] { color: var(--accent-text); background: var(--warn); } +[data-module="ableset"] .as-badge.b-clock[data-tone="alert"] { color: var(--accent-text); background: var(--danger); } + +[data-module="ableset"] .as-next { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-width: 0; + gap: clamp(1px, 0.6cqh, 6px); +} +[data-module="ableset"] .as-next-label { + font-size: clamp(8px, 5cqh, 30px); + font-weight: 700; + color: color-mix(in srgb, var(--live) 55%, var(--muted)); +} +[data-module="ableset"] .as-next.is-queued .as-next-label { color: var(--warn); } +[data-module="ableset"] .as-next-title { + font-size: clamp(11px, 12cqh, 90px); + font-weight: 500; + line-height: 1.1; + color: color-mix(in srgb, var(--live) 45%, var(--muted)); +} +[data-module="ableset"] .as-left { + font-size: clamp(8px, 6cqh, 36px); + color: color-mix(in srgb, var(--live) 40%, var(--muted)); +} + +/* ── SONG SECTIONS ──────────────────────────────────────────────── */ + +[data-module="ableset"] .as-sections .as-body { padding: clamp(3px, 3cqmin, 16px) 0; gap: clamp(2px, 2cqh, 10px); } +[data-module="ableset"] .as-sections .as-song-name { + flex-shrink: 0; + text-align: center; + padding: 0 clamp(4px, 3cqw, 20px); + font-size: clamp(9px, 13cqh, 28px); + font-weight: 700; + color: var(--muted); +} +[data-module="ableset"] .as-viewport { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); +} +[data-module="ableset"] .as-track { + position: absolute; + top: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + gap: clamp(4px, 2cqw, 18px); + transition: transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1); + will-change: transform; +} +[data-module="ableset"] .as-track.no-anim, +[data-module="ableset"] .as-track.no-anim .as-pill { transition: none !important; } + +[data-module="ableset"] .as-pill { + --p: 0%; + --sect: var(--accent); + position: relative; + flex-shrink: 0; + display: flex; + align-items: center; + height: min(78%, 22cqw); + padding: 0 0.8em; + border-radius: 0.35em; + font-size: clamp(9px, 26cqh, 64px); + font-weight: 700; + line-height: 1; + white-space: nowrap; + overflow: hidden; + color: var(--muted); + background: color-mix(in srgb, var(--text) 8%, var(--panel)); + border: 2px solid transparent; + transition: background 0.4s, color 0.4s, border-color 0.4s, opacity 0.5s, transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1); +} +[data-module="ableset"] .as-pill.has-color { background: color-mix(in srgb, var(--sect) 22%, var(--panel)); } +[data-module="ableset"] .as-pill.is-past { opacity: 0.45; } +[data-module="ableset"] .as-pill.is-next { border-color: color-mix(in srgb, var(--sect) 60%, transparent); color: var(--text); } +[data-module="ableset"] .as-pill.is-current { + color: var(--accent-text); + background: color-mix(in srgb, var(--sect) 55%, var(--panel)); +} +[data-module="ableset"] .as-pill-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: var(--sect); +} +[data-module="ableset"] .as-pill.is-current .as-pill-fill { width: var(--p); } +[data-module="ableset"] .as-pill-text { position: relative; } +[data-module="ableset"] .as-pill.is-empty { color: var(--muted); } + +/* the lottery wheel: faces on a drum turning towards the viewer */ +[data-module="ableset"] .is-wheel .as-viewport { + perspective: 900px; + -webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent); + mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent); +} +[data-module="ableset"] .is-wheel .as-track { + --face-h: 30px; + inset: 50% 0 auto 0; + display: block; + height: 0; + transform-style: preserve-3d; + transition: none; +} +[data-module="ableset"] .is-wheel .as-pill { + position: absolute; + left: 50%; + top: 0; + translate: -50% 0; + height: var(--face-h); + justify-content: center; + min-width: 55%; + max-width: 94%; + font-size: clamp(9px, calc(var(--face-h) * 0.55), 72px); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +/* ── SETLIST ─────────────────────────────────────────────────────── */ + +[data-module="ableset"] .as-setlist .as-body { justify-content: flex-start; gap: 6px; padding: 8px; } +[data-module="ableset"] .as-set-head { + flex-shrink: 0; + font-size: 0.8rem; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="ableset"] .as-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; scrollbar-width: thin; } +[data-module="ableset"] .as-row { + --p: 0%; + --song: var(--accent); + position: relative; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.35em 0.55em; + border-radius: 6px; + font-size: clamp(11px, 5.5cqw, 19px); + background: color-mix(in srgb, var(--text) 5%, transparent); + overflow: hidden; + flex-shrink: 0; +} +[data-module="ableset"] .as-row-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: color-mix(in srgb, var(--accent) 22%, transparent); } +[data-module="ableset"] .as-row.is-current .as-row-fill { width: var(--p); } +[data-module="ableset"] .as-row-name { position: relative; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +[data-module="ableset"] .as-row-dur { position: relative; color: var(--muted); font-size: 0.85em; } +[data-module="ableset"] .as-swatch { position: relative; width: 0.55em; height: 0.55em; border-radius: 50%; background: var(--song); flex-shrink: 0; } +[data-module="ableset"] .as-row.is-past { opacity: 0.4; } +[data-module="ableset"] .as-row.is-current { font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); } +[data-module="ableset"] .as-row.is-next { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--text) 40%, transparent); } +[data-module="ableset"] .as-row.is-queued { box-shadow: inset 3px 0 0 var(--warn); color: var(--warn); } +[data-module="ableset"] .as-set-foot { flex-shrink: 0; font-size: 0.85rem; color: var(--muted); text-align: right; } +[data-module="ableset"] .as-empty { color: var(--muted); padding: 1em; text-align: center; } + +/* ── single readouts: transport, tempo, times, section, next ────── */ + +[data-module="ableset"] .as-big-box { display: flex; justify-content: center; min-width: 0; width: 100%; } +[data-module="ableset"] .as-big { + font-size: clamp(14px, 38cqh, 220px); + font-weight: 800; + line-height: 1; + color: var(--tone); + text-align: center; + transition: color 0.3s; +} +[data-module="ableset"] .as-big small { font-size: 0.35em; font-weight: 700; color: var(--muted); } +[data-module="ableset"] .as-caption { + text-align: center; + font-size: clamp(8px, 12cqh, 30px); + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +[data-module="ableset"] .as-sub { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.25em 1em; + font-size: clamp(8px, 12cqh, 34px); + color: var(--muted); + text-align: center; + white-space: nowrap; +} +[data-module="ableset"] .as-sub:empty { display: none; } + +/* A tone washes the tile's background, so a wall of them reads at a glance. */ +[data-module="ableset"] .as-transport, +[data-module="ableset"] .as-song-time, +[data-module="ableset"] .as-section, +[data-module="ableset"] .as-status, +[data-module="ableset"] .as-next-song { + background: color-mix(in srgb, var(--tone) 9%, transparent); + transition: background 0.3s; +} +[data-module="ableset"] .as-root[data-tone="muted"], +[data-module="ableset"] .as-root[data-tone=""] { background: none; } + +[data-module="ableset"] .as-transport .as-big { text-transform: uppercase; font-size: clamp(12px, 34cqh, 180px); } +[data-module="ableset"] .as-transport .as-loop { color: var(--warn); font-weight: 700; } + +[data-module="ableset"] .as-tempo .as-big { color: var(--text); } +[data-module="ableset"] .as-beats { display: flex; justify-content: center; gap: clamp(3px, 3cqw, 14px); flex-shrink: 0; } +[data-module="ableset"] .as-beats i { + width: clamp(6px, 11cqh, 30px); + aspect-ratio: 1; + border-radius: 50%; + background: color-mix(in srgb, var(--text) 14%, var(--panel)); + transition: background 0.08s; +} +[data-module="ableset"] .as-beats i.is-lit { background: var(--text); } +[data-module="ableset"] .as-beats i.is-down.is-lit { background: var(--accent); } + +[data-module="ableset"] .as-song-time .as-bar { height: clamp(3px, 5cqh, 14px); } + +[data-module="ableset"] .as-section .as-big { text-transform: uppercase; } +[data-module="ableset"] .as-section.has-song-color .as-big { color: var(--song); } +[data-module="ableset"] .as-section .as-count { color: var(--tone); font-weight: 700; } +[data-module="ableset"] .as-sect-bar { + --p: 0%; + height: clamp(3px, 4cqh, 12px); + border-radius: 999px; + background: color-mix(in srgb, var(--text) 12%, var(--panel)); + overflow: hidden; + flex-shrink: 0; +} +[data-module="ableset"] .as-sect-bar > div { height: 100%; width: var(--p); background: var(--tone); } + +[data-module="ableset"] .as-next-song .as-big { color: var(--text); font-size: clamp(12px, 30cqh, 150px); } +[data-module="ableset"] .as-queued { + align-self: center; + font-size: clamp(8px, 11cqh, 28px); + font-weight: 700; + padding: 0.2em 0.6em; + border-radius: 0.3em; + color: var(--accent-text); + background: var(--warn); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* ── STATUS ──────────────────────────────────────────────────────── */ + +[data-module="ableset"] .as-status .as-body { justify-content: flex-start; padding: 8px 10px; } +[data-module="ableset"] .as-rows { display: flex; flex-direction: column; gap: 4px; } +[data-module="ableset"] .as-srow { + --dot: var(--muted); + display: flex; + align-items: center; + gap: 0.55em; + font-size: clamp(11px, 4.5cqw, 18px); + padding: 0.3em 0.5em; + border-radius: 6px; + background: color-mix(in srgb, var(--dot) 10%, transparent); +} +[data-module="ableset"] .as-srow[data-tone="ok"] { --dot: var(--accent); } +[data-module="ableset"] .as-srow[data-tone="warn"] { --dot: var(--warn); } +[data-module="ableset"] .as-srow[data-tone="alert"] { --dot: var(--danger); } +[data-module="ableset"] .as-srow[data-tone=""] { --dot: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); } +[data-module="ableset"] .as-srow i { width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--dot); flex-shrink: 0; } +[data-module="ableset"] .as-sk { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +[data-module="ableset"] .as-sv { font-weight: 700; color: var(--dot); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; } +[data-module="ableset"] .as-srow[data-tone=""] .as-sv { color: var(--text); } diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index fb6e1a3..685bc75 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -1,8 +1,8 @@ /* PCO Plan — client part. - The module presents two tiles and this is the entry for both: the Teams - tile (picker entry "teams") is built by teams-client.js, everything below - draws the Plan tile. + 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 @@ -13,6 +13,8 @@ the connection settings. */ import createTeams from "./teams-client.js"; +import createKey from "./key-client.js"; +import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -21,8 +23,9 @@ const MINUS_SVG = ' 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 || {}; @@ -531,11 +663,12 @@ 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)}"]`); - row?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (row) keepInView(row); } } } @@ -610,6 +743,7 @@ function createPlan({ root, moduleApi }) { } function tick() { + renderAbleSet(); if (!state?.plan) return; renderClock(); if (state.live?.following && on('showItemRuntime')) renderLiveMarks(); @@ -621,6 +755,7 @@ 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(); } @@ -641,6 +776,16 @@ 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(); + }, }, }); } diff --git a/modules/pco-plan/key-client.js b/modules/pco-plan/key-client.js new file mode 100644 index 0000000..1297227 --- /dev/null +++ b/modules/pco-plan/key-client.js @@ -0,0 +1,181 @@ +/* PCO Plan — the Song Key tile. + + One thing, big: the key of the plan item on screen. It reads the same + streamed state the Plan tile does, so it needs no connection of its own; + which item is current comes from the ProPresenter module, as ever. */ + +import { parseKey, prettyKey } from "./key-notation.js"; + +export default function createKey({ root, moduleApi }) { + let state = null; + let stream = null; + let feedOffline = false; + + root.innerHTML = ` +
+
+
+
+
`; + + 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. + }, + }; +} diff --git a/modules/pco-plan/key-notation.js b/modules/pco-plan/key-notation.js new file mode 100644 index 0000000..f5b7dba --- /dev/null +++ b/modules/pco-plan/key-notation.js @@ -0,0 +1,25 @@ +/* PCO Plan — how a song key is written. + + Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this + module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge + on an item and the Song Key tile all agree. Shared rather than repeated + because the day one of them changes, they all should. */ + +/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */ +export function parseKey(raw) { + const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim()); + if (!match) return null; + const [, note, accidental, quality] = match; + return { + note, + accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental, + quality: quality.trim(), + }; +} + +/** The same thing as one string, for a badge or a caption. */ +export function prettyKey(raw) { + const parts = parseKey(raw); + if (!parts) return String(raw || '').trim(); + return `${parts.note}${parts.accidental}${parts.quality}`; +} diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 6c0d9f6..1b17e5b 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,9 +1,9 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.7.0", + "version": "1.9.0", "proddash": ">=1.4.0", - "description": "Order of service and team assignments from Planning Center Services", + "description": "Order of service, team assignments and the song key from Planning Center Services", "provides": [ "timers" ], @@ -382,6 +382,18 @@ "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": [ @@ -609,6 +621,36 @@ "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 + } + } } ] } diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index d1136bd..89ef471 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -9,6 +9,10 @@ * * 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 @@ -33,6 +37,13 @@ * 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 * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and @@ -59,7 +70,7 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching'); +const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching'); const { MINUTE, serviceTimes, @@ -85,6 +96,12 @@ 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; @@ -153,6 +170,28 @@ 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 { @@ -1258,6 +1297,170 @@ 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); @@ -1269,6 +1472,10 @@ module.exports = { try { nnRequest?.destroy(); } catch { /* gone */ } nnRequest = null; connectNowNext(); + clearTimeout(absRetryTimer); + try { absRequest?.destroy(); } catch { /* gone */ } + absRequest = null; + connectAbleSet(); } /** @@ -1527,6 +1734,9 @@ 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(() => { @@ -1559,6 +1769,7 @@ module.exports = { timerStreams, pco, getState: () => state, + getAbleSet: () => abs, learnPort, refresh() { clearTimeout(pollTimer); @@ -1580,7 +1791,10 @@ 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 */ } @@ -1622,7 +1836,11 @@ module.exports = { return { 'GET /state': (req, res) => { current?.learnPort(req.headers.host); - json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() }); + json(res, 200, { + state: current ? current.getState() : clearedState(), + ableset: current ? current.getAbleSet() : clearedAbleSet(), + now: Date.now(), + }); }, 'GET /stream': (req, res) => { @@ -1630,6 +1848,7 @@ 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 */ } diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 3b8561f..035ee48 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -86,6 +86,7 @@ } [data-module="pco-plan"] .pp-row { + position: relative; display: flex; align-items: flex-start; gap: 0.6em; @@ -205,7 +206,9 @@ [data-module="pco-plan"] .pp-now-tag { display: none; - padding: 0.05em 0.45em; + /* Optically centred like the section chips: uppercase has no descenders, + so the capitals sat 0.59px low in the pill. */ + padding: 0.018em 0.45em 0.082em; border-radius: 999px; background: var(--pp-current); color: var(--bg); @@ -589,3 +592,155 @@ [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; } diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 44b975e..ff37685 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -70,6 +70,7 @@ const DEFAULTS = { showDetail: true, showSeconds: true, leadingZeros: false, + showLoops: false, clock24h: true, clockSeconds: true, clockDate: false, @@ -123,10 +124,13 @@ 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(sourceUpdatedAt) || now; + const at = Number(timer.asOf) || 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') { @@ -381,7 +385,12 @@ export default function create({ root, moduleApi }) { for (const source of state.sources) { for (const timer of source.timers) { if (keyOf(source, timer) !== solo) continue; - return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; + // 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 }] }]; } } // The timer this tile was added for is not offered right now. Nothing is @@ -395,7 +404,11 @@ export default function create({ root, moduleApi }) { const items = []; for (const timer of source.timers) { const key = keyOf(source, timer); - if (!hidden.has(key)) items.push({ key, 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 }); } // Every timer of a live source hidden on purpose: nothing to say. if (!items.length && source.timers.length) continue; @@ -647,9 +660,25 @@ export default function create({ root, moduleApi }) { statusTone = timer.status?.tone || 'muted'; } } else { - const value = valueAt(timer, source.updatedAt, now); + 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; + } text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' }); - tone = toneFor(timer, value); + // 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); statusText = timer.status?.text || defaultStatus(timer, value, tone); statusTone = timer.status?.tone || tone; detailText = timer.detail || ''; diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js index 6cba673..d98a1b8 100644 --- a/modules/propresenter-timers/ltc-listener.js +++ b/modules/propresenter-timers/ltc-listener.js @@ -10,13 +10,23 @@ * onUpdate roughly 4×/s: * * { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy - * { error: '' } not capturing + * { error: '', detail: '' } not capturing * - * The capture binary is per-machine (gitignored): if it is missing this - * tries one swiftc compile of the source next to it, and otherwise says to - * build it on any same-architecture Mac and copy it over. A dead child is - * restarted with backoff — fast for a crash, slow for config-shaped - * failures (unknown device, ambiguous name) that never fix themselves. + * Error strings are two-tier on purpose: `error` is what a dashboard tile + * shows (short — "Not configured — see Admin"), `detail` is the full story + * for the admin health line and the log. + * + * The capture 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. * * macOS gotcha this file can't fix: microphone permission. A ProdDash * server launched over SSH or from a bare launchd job gets SILENT ZEROS @@ -27,7 +37,7 @@ const path = require('path'); const fs = require('fs'); -const { spawn, spawnSync } = require('child_process'); +const { spawn } = require('child_process'); const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc'); const RESTART_MS = 5000; @@ -41,6 +51,100 @@ 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 @@ -75,25 +179,24 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { return 'nosignal'; } - function ensureBinary() { - if (fs.existsSync(bin)) return ''; - const src = bin + '.swift'; - log('ltc-capture binary missing — trying a one-time swiftc build'); - const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 }); - if (built.status === 0 && fs.existsSync(bin)) { - log('ltc-capture compiled'); - return ''; - } - return 'LTC capture tool missing — build it on any Apple Silicon Mac with ' - + '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` ' - + 'and copy it here'; - } - function start() { if (stopped) return; - const missing = ensureBinary(); - if (missing) { - report({ error: missing }); + const tool = 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. scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -113,7 +216,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { const channels = Number(m[2]); const rate = Number(m[3]); if (channel > channels) { - report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` }); + report({ + error: 'Channel not on this device — see Admin', + detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`, + }); try { child.kill(); } catch { /* exiting anyway */ } return; } @@ -135,17 +241,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }); child.on('error', (err) => { - report({ error: `LTC capture failed to start: ${err.message}` }); + report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` }); }); child.on('exit', (code) => { child = null; if (stopped) return; // The capture's own failure text (unknown device, ambiguous name, - // permission hint) is the most useful thing we can show. + // permission hint) is the most useful DETAIL we have; the tile just + // needs to know the input is down and where to fix it. const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l)); const quickDeath = Date.now() - startedAt < 3000; - report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') }); + report({ + error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…', + detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'), + }); scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS); }); } @@ -188,4 +298,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }; } -module.exports = { createLtcListener }; +module.exports = { createLtcListener, ensureCaptureBinary }; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 5e70595..a2b9d43 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.4.1", + "version": "1.6.0", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ @@ -161,6 +161,12 @@ "default": false, "group": "Show" }, + "showLoops": { + "type": "switch", + "label": "Looping videos", + "default": false, + "group": "Show" + }, "showHeadings": { "type": "switch", "label": "Source headings", diff --git a/modules/propresenter-timers/phase.js b/modules/propresenter-timers/phase.js new file mode 100644 index 0000000..b5f2ba1 --- /dev/null +++ b/modules/propresenter-timers/phase.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Phase lock for whole-second timer reports (the ProPresenter poller). + * + * ProPresenter reports a running timer as whole seconds — trunc(value): + * "12:34" means the true remaining time is anywhere in [754 s, 755 s). The + * old poller re-anchored a tile's local count at the bottom of that interval + * on every poll, so the displayed digit ran up to a second ahead and then + * got yanked back a step whenever a fresh poll happened to sample the next + * flip later than the last one did — the poll clock and ProPresenter's + * second boundaries drift through each other, so tiles flickered + * "…12:33 → 12:34 → 12:33…" about once a second. + * + * This tracker turns the quantized reports into ONE stable anchor + * { valueMs, at } — the continuous value at a fixed instant — which the + * client extrapolates from. Rules: + * + * - The only real information in a poll is a FLIP: the report stepping one + * second in the direction of travel. At the flip instant the true value + * equals the interval boundary just crossed, and the flip happened between + * the last two polls, so their midpoint dates it to within half a poll. + * - While a poll's report matches what the anchor predicts (within TOL_MS + * around the reported second's interval), the anchor HOLDS — the same + * numbers are republished, so consumers count along one steady clock + * instead of a freshly re-phased one per sample. + * - A report the anchor can't explain re-anchors: at the flip boundary when + * this poll saw a clean one-second flip, else mid-interval (first sight, + * an operator reset/seek, a resume, or clock drift past TOL_MS). + * - Before the first flip the anchor sits mid-interval: at most ±½ s off, + * and the first flip (≤1 s away) locks it — instead of the old guaranteed + * whole-second bias one way. + * + * trunc() semantics also cover overrun: a countdown at −12.4 s reports + * "-12", so a negative countdown's interval is (W−1 s, W] and its flip + * boundary is W itself, where a positive countdown's are [W, W+1 s) and + * W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above + * crosses +1 s. + */ + +/** How far a report may sit outside the anchor's prediction before it means + something real. A flip is dated to the midpoint of two polls, so its own + error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll + runs long); the tolerance must sit ABOVE that or consecutive flips + re-anchor each other forever, and safely below a full second so a real + one-second seek still registers. */ +const TOL_MS = 450; + +/** The continuous-value interval implied by a whole-second report. */ +function intervalFor(kind, wholeMs) { + if (kind === 'countdown') { + if (wholeMs === 0) return { lo: -1000, hi: 1000 }; + if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs }; + } + return { lo: wholeMs, hi: wholeMs + 1000 }; +} + +/** The continuous value at the instant a running timer's report flips TO wholeMs. */ +function boundaryFor(kind, wholeMs) { + if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000; + return wholeMs; +} + +/** + * One tracker per timer. Feed one observation per poll; get back what to + * publish: `{ valueMs, asOf }` — a continuous value at a fixed instant, + * stable across polls while the reports stay consistent with it. Not + * running (or no value): the report is returned verbatim, dated at the poll. + */ +function createTimerPhase() { + let anchor = null; // { valueMs, at } + let kind = ''; + let lastWhole = null; + let lastAt = 0; + let wasRunning = false; + + function observe({ kind: rawKind, running, wholeMs, pollAt }) { + const k = rawKind === 'countdown' ? 'countdown' : 'elapsed'; + const dir = k === 'countdown' ? -1 : 1; + const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0 + && lastWhole !== null && wholeMs !== null + && wholeMs - lastWhole === dir * 1000; + const prevAt = lastAt; + lastWhole = wholeMs; + lastAt = pollAt; + wasRunning = Boolean(running); + if (k !== kind) anchor = null; + kind = k; + if (!running || wholeMs === null) { + anchor = null; + return { valueMs: wholeMs, asOf: pollAt }; + } + const { lo, hi } = intervalFor(k, wholeMs); + // A mid-interval anchor is provisional — a guess up to ±½ s off that the + // tolerance check may fail to catch for a long time (its error only + // shows at a poll landing more than TOL_MS past a flip). The first + // clean flip is strictly better evidence (±½ poll), so it always wins. + if (anchor && !(anchor.provisional && flip)) { + const predicted = anchor.valueMs + dir * (pollAt - anchor.at); + if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) { + return { valueMs: anchor.valueMs, asOf: anchor.at }; + } + } + anchor = flip + ? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false } + : { valueMs: (lo + hi) / 2, at: pollAt, provisional: true }; + return { valueMs: anchor.valueMs, asOf: anchor.at }; + } + + return { observe }; +} + +module.exports = { createTimerPhase, TOL_MS }; diff --git a/modules/propresenter-timers/phase.selftest.js b/modules/propresenter-timers/phase.selftest.js new file mode 100644 index 0000000..f434113 --- /dev/null +++ b/modules/propresenter-timers/phase.selftest.js @@ -0,0 +1,205 @@ +'use strict'; + +/** + * Self-test for phase.js — the whole-second phase lock — plus the client's + * never-tick-backwards guard, driven by a simulated ProPresenter: + * + * node modules/propresenter-timers/phase.selftest.js + * + * The simulation runs a true continuous timer, reports it the way + * ProPresenter does (truncated whole seconds, sampled every ~500 ms with + * jitter), feeds the reports through createTimerPhase(), then renders the + * displayed value at 25 ms steps exactly the way client.js does (extrapolate + * from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts: + * + * - the displayed digits NEVER step against the direction of travel + * (the reported bug: countdown tiles flicking back to the previous + * second about once a second), + * - each displayed second dwells ~1 s, + * - the published anchor stays within tolerance of the true value, + * - operator resets/seeks snap within a poll or two, + * - pause → resume re-locks, + * - a ProPresenter whose clock drifts against the server's stays locked. + */ + +const { createTimerPhase, TOL_MS } = require('./phase'); + +let checks = 0; +let failures = 0; +function ok(name, cond, detail) { + checks += 1; + if (!cond) { + failures += 1; + console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +/** Deterministic pseudo-random (so a failure reproduces). */ +function makeRand(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 2 ** 32; + }; +} + +/** ProPresenter's report: whole seconds, truncated toward zero. */ +const trunc = (ms) => Math.trunc(ms / 1000) * 1000; + +/** + * Run one scenario. `trueValueAt(t)` is the continuous value (countdown + * remaining or elapsed) on the SERVER's clock; null = not reported (paused + * timers report a frozen value via `frozen`). + */ +function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) { + const rand = makeRand(seed); + const phase = createTimerPhase(); + const dir = kind === 'countdown' ? -1 : 1; + + const polls = []; + for (let t = 0; t <= durationMs;) { + const running = runningAt(t); + const value = trueValueAt(t); + polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running }); + t += pollMs + Math.floor(rand() * jitterMs); + } + + // Render like client.js: between polls, extrapolate from the latest + // anchor; clamp sub-1.5 s regressions while running. + const shown = []; // { t, sec, err } + let mono = null; + let p = 0; + for (let t = polls[0].t; t <= durationMs; t += 25) { + while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1; + const { out, running } = polls[p]; + if (!running || out.valueMs === null) { + mono = null; + continue; + } + let value = out.valueMs + dir * (t - out.asOf); + if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono; + mono = value; + const truth = trueValueAt(t); + shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth }); + } + return { name, dir, shown }; +} + +/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs + from legitimate corrections (absorbing clock drift re-places a boundary + by up to TOL_MS once in a while). */ +function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) { + let regressions = 0; + let maxAbsErr = 0; + const changes = []; + for (let i = 1; i < shown.length; i += 1) { + const step = shown[i].sec - shown[i - 1].sec; + if (step !== 0 && Math.sign(step) !== dir) regressions += 1; + if (step !== 0) changes.push(shown[i].t); + if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err)); + } + // Whole dwells only (drop the partial first and trailing segments), after + // the lock settles — the first observed flip corrects the provisional + // mid-interval anchor once, stretching or squeezing a boundary in the + // timer's first moments, which no one can see on a wall. A countdown + // crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit + // for the whole (−1 s, +1 s) span, like ProPresenter's own display — so + // allow one ~2 s dwell; everything else must be ~1 s. + const settled = shown.length ? shown[0].t + 2500 : 0; + const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d); + const near = (d, target) => Math.abs(d - target) <= 200; + const long = dwells.filter((d) => near(d, 2000)).length; + const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length; + ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`); + ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1, + `${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`); + ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`); +} + +/* 1. Plain countdown, many sub-second phases and poll seeds. */ +for (let i = 0; i < 12; i += 1) { + const phase0 = (i * 83) % 1000; + const run = simulate({ + name: `countdown q=${phase0}ms seed=${i}`, + kind: 'countdown', + seed: 1000 + i, + durationMs: 30000, + trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun + }); + assertSteady(run); +} + +/* 2. Elapsed timer (counts up). */ +for (let i = 0; i < 6; i += 1) { + const run = simulate({ + name: `elapsed seed=${i}`, + kind: 'elapsed', + seed: 2000 + i, + durationMs: 25000, + trueValueAt: (t) => 400 + i * 137 + t, + }); + assertSteady(run); +} + +/* 3. Operator reset mid-run: value jumps back up to full length. */ +{ + const run = simulate({ + name: 'reset', + kind: 'countdown', + seed: 7, + durationMs: 24000, + trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)), + }); + // After the reset the shown value must reach the new region within ~2 polls. + const after = run.shown.filter((s) => s.t >= 13400); + ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples'); + // Before and after the jump, digits stay monotonic within each phase. + const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) }; + assertSteady(pre, { maxErr: 700 }); +} + +/* 4. Pause, then resume: no stale-anchor explosion. */ +{ + const frozen = 8000; + const run = simulate({ + name: 'pause/resume', + kind: 'countdown', + seed: 11, + durationMs: 26000, + trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)), + runningAt: (t) => t < 6000 || t >= 14000, + }); + const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) }; + assertSteady(resumed); +} + +/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor + re-locks over time instead of accumulating a visible error. */ +{ + const run = simulate({ + name: 'clock drift', + kind: 'countdown', + seed: 13, + durationMs: 120000, + trueValueAt: (t) => 130000 - t * 1.002, + }); + // Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary + // by up to TOL_MS to absorb it — a slightly long/short dwell pair, never + // a backwards step (the separate regression check still demands zero). + assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 }); +} + +/* 6. Idle timers pass through untouched. */ +{ + const phase = createTimerPhase(); + const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 }); + ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000); + const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 }); + ok('missing value passes through', b.valueMs === null && b.asOf === 1500); +} + +if (failures) { + console.error(`\n${failures} of ${checks} checks FAILED`); + process.exit(1); +} +console.log(`phase.selftest: all ${checks} checks passed`); diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 5b1dba6..4235b14 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -81,7 +81,8 @@ const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); -const { createLtcListener } = require('./ltc-listener'); +const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener'); +const { createTimerPhase } = require('./phase'); const manifest = require('./module.json'); /** This module's id — left out of provider discovery (we don't consume ourselves). */ @@ -209,6 +210,7 @@ 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), @@ -250,6 +252,23 @@ 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}`); @@ -325,9 +344,21 @@ function createProPresenterSource({ config, log }) { const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0); state = atStart ? 'idle' : 'paused'; } - 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; + // 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 text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped'; const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted'; return { @@ -335,6 +366,7 @@ function createProPresenterSource({ config, log }) { label: (live && live.name) || (cfg && cfg.name) || 'Timer', kind, state, + asOf, startedAt, elapsedMs, remainingMs, @@ -367,16 +399,143 @@ 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'; } - function message() { + /** 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() { 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}…`; @@ -384,7 +543,7 @@ function createProPresenterSource({ config, log }) { function publish() { if (stopped) return; - emit({ status: status(), message: message(), timers, updatedAt }); + emit({ status: status(), message: tileMessage(), timers, updatedAt }); } async function pollTick() { @@ -400,6 +559,9 @@ 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) { @@ -430,7 +592,7 @@ function createProPresenterSource({ config, log }) { clearTimeout(pollTimer); }, health() { - return { status: status(), message: message() }; + return { status: status(), message: adminMessage() }; }, }; } @@ -444,7 +606,9 @@ function createLtcSource({ config, log, moduleDir }) { let emit = () => {}; let listener = null; let stopped = false; - /** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */ + /** The listener's latest word: { state, time, fps, df, ageMs } | + { error, detail } (error = short tile text, detail = the admin/log + story) | null while starting. */ let word = null; function rateText() { @@ -496,8 +660,16 @@ 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: 'LTC timecode is on but no audio device is selected — pick one in Admin' }; + word = { + error: 'Not configured — see Admin', + detail: 'LTC timecode is on but no audio device is selected — pick one in Admin', + }; publish(); return; } @@ -519,7 +691,8 @@ function createLtcSource({ config, log, moduleDir }) { listener = null; }, health() { - return { status: status(), message: message() }; + // The admin page gets the full story; the tiles got the short one. + return { status: status(), message: (word && word.error && word.detail) || message() }; }, }; } @@ -568,7 +741,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) { let silenceTimer = null; let backoff = BACKOFF_MIN_MS; - function message() { + /** 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() { if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`; if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…'; return `unreachable — ${lastError}`; @@ -576,7 +757,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) { function publish() { if (stopped) return; - emit({ label, status: state, message: message(), timers, updatedAt }); + emit({ label, status: state, message: tileMessage(), timers, updatedAt }); } function accept(raw) { @@ -754,7 +935,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) { if (!stopped && !stream && via === 'poll') connectStream(); }, health() { - return { status: state, message: message() }; + return { status: state, message: adminMessage() }; }, }; } @@ -922,6 +1103,8 @@ 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. */ @@ -935,6 +1118,7 @@ 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; } @@ -992,12 +1176,17 @@ 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' }; - if (t.state === 'running' && v.updatedAt > 0) { - const dt = Math.max(0, now - v.updatedAt); + // 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 (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); } } @@ -1138,7 +1327,8 @@ 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' - : `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} 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`; // Layouts from before 1.3.0 hold 'timer:' / '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 } : {}) }); @@ -1147,7 +1337,7 @@ module.exports = { return out; }, - routes() { + routes({ log }) { return { 'GET /state': (req, res) => { sendJson(res, 200, { state: current ? current.getState() : emptyState() }); @@ -1170,7 +1360,15 @@ 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 = ''; diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 2e07058..8eea9e5 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.4.1", + "version": "0.4.3", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index a735d03..0ae966a 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -20,7 +20,7 @@ */ const path = require('path'); -const { createSplListener } = require('./spl-listener'); +const { createSplListener, ensureCaptureBinary } = require('./spl-listener'); /** Reference level of a sound level calibrator. Effectively always 94 dB. */ const CALIBRATOR_DB = 94; @@ -114,11 +114,19 @@ 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 }) }; @@ -177,6 +185,9 @@ 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(); @@ -300,6 +311,13 @@ 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, { @@ -438,6 +456,10 @@ 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, { diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index 1a9381b..ebe1276 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -17,7 +17,8 @@ * 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: see ensureBinary for what that cost the first time it was wrong. + * 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. * * 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 @@ -30,6 +31,74 @@ 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. */ @@ -53,64 +122,13 @@ function createSplListener(opts) { let stallTimer = null; let startedAt = 0; let meta = null; - let building = false; - let buildFailed = false; let lastFrameWall = 0; - 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. - */ + /** '' when the tool is ready, otherwise what to say about it. */ function ensureBinary() { - 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'; + return ensureCaptureBinary({ moduleDir, log, onReady() { + if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } + } }); } function scheduleRestart(ms) { @@ -135,9 +153,13 @@ function createSplListener(opts) { const missing = ensureBinary(); if (missing) { onUpdate({ error: missing }); - // 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); + // 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); return; } @@ -236,4 +258,4 @@ function createSplListener(opts) { }; } -module.exports = { createSplListener }; +module.exports = { createSplListener, ensureCaptureBinary }; diff --git a/package.json b/package.json index b18a3f8..d70640d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.9.0", + "version": "1.10.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.css b/public/admin/admin.css index 12f0018..0a7ae91 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -443,6 +443,7 @@ 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; } diff --git a/public/admin/admin.js b/public/admin/admin.js index f035683..18dff9b 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -962,6 +962,13 @@ 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(); @@ -1163,6 +1170,7 @@ function renderAvailable(cat) { els.updateSlot.appendChild(btn); } const available = cat.available || []; + installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2; if (!available.length) { const none = document.createElement('div'); none.className = 'layouts-empty'; @@ -1205,11 +1213,46 @@ function renderAvailable(cat) { btn.disabled = !item.compatible; btn.title = item.compatible ? '' : item.reason; btn.addEventListener('click', () => installFromCatalog(item, btn)); + if (item.compatible) { + const pick = document.createElement('input'); + pick.type = 'checkbox'; + pick.className = 'catalog-pick'; + pick.dataset.id = item.id; + pick.dataset.name = item.name; + pick.title = 'Tick several, then Install selected'; + row.append(pick); + } row.append(main, btn); availableList.appendChild(row); } } +const installSelectedBtn = document.getElementById('install-selected'); + +/** The ticked modules, in one request; the answer says which ones landed. */ +async function installSelected() { + const picks = [...availableList.querySelectorAll('.catalog-pick:checked')]; + if (!picks.length) return toast('Tick the modules to install first', true); + installSelectedBtn.disabled = true; + installSelectedBtn.textContent = `Installing ${picks.length}…`; + try { + const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) }); + const done = (r.results || []).filter((x) => x.ok).length; + const failed = (r.results || []).filter((x) => !x.ok); + toast(failed.length + ? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}` + : `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0); + await loadState(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } finally { + installSelectedBtn.disabled = false; + installSelectedBtn.textContent = 'Install selected'; + } +} +installSelectedBtn.addEventListener('click', installSelected); + async function installFromCatalog(item, btn) { btn.disabled = true; const label = btn.textContent; diff --git a/public/admin/index.html b/public/admin/index.html index ef9fe9c..fa819f0 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -58,6 +58,7 @@