Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00
michael c11389473f Merge pull request 'Release builds: Intel disk images, a Windows bundle, and where to download them' (#24) from claude/release-builds into main 2026-09-30 09:58:11 -04:00
Waters ChurchandClaude Fable 5.1 c6a44e6ddc Windows build script: executable, like the Mac one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:54:51 -04:00
Waters ChurchandClaude Fable 5.1 2f14b30d68 Windows bundle: no modules either, and the read-me says where they come from
The same change the Mac build got in 1.10.0: the bundle carries the shell
alone; the admin page's catalog installs modules into %APPDATA%\ProdDash.
README.txt gains the step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:46:51 -04:00
Waters Church b5d39c60dc Merge remote-tracking branch 'origin/main' into claude/release-builds 2026-09-30 09:46:12 -04:00
michael 407eaecc23 Merge pull request 'Shell 1.10.0: a packaged ProdDash carries no modules' (#36) from claude/unbundled-shell into main 2026-09-30 09:42:23 -04:00
Waters ChurchandClaude Fable 5.1 ac1f17b9ed Shell 1.10.0: a packaged ProdDash carries no modules
The macOS app and the Windows bundle shipped every module in the repo, on
by default, and a shell update silently added whatever new module had
landed on main — Band Lineup appeared on AB that way. A module was also
only as fresh as the last shell update. Now a packaged install (no .git —
the same test the updater uses to choose the archive over git pull)
carries no modules at all: every one arrives from the admin page's catalog
into the data directory, and the shell's own update never writes modules/.
A git checkout keeps running the modules in its tree.

Existing installs move over by themselves: at the first start, the bundled
modules that are switched on are copied into the data directory as
installed copies (a compiled helper keeps its mode), an uninstalled one is
dropped, and the app folder's modules/ is removed. Settings are keyed by
module id, so nothing changes for the operator.

Around it: the dashboard's empty state says "No modules yet — open Admin"
when the registry is empty; the admin page says "Nothing installed yet",
puts a checkbox on every compatible catalog entry and an Install selected
button (POST /api/admin/modules/install {ids}) that fetches them in one go.
The Mac build stops copying modules/ and no longer compiles or signs Swift
helpers — those ship prebuilt inside a module, per the guide.

tools/unbundled.test.js drives a packaged copy of the shell through the
move, an archive update that must not bring modules back, and a multi-install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:34:10 -04:00
michael c37575f662 Merge pull request 'Module rules: a native helper ships prebuilt, for Apple silicon and Windows' (#35) from claude/module-helper-rules into main 2026-09-29 16:45:35 -04:00
Waters ChurchandClaude Fable 5.1 07e4c78417 Module rules: a native helper ships prebuilt, for Apple silicon and Windows
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 the catalog
delivers exactly the folder that is in the repo — so a module's native
program (audio capture, a decoder) ships already built, inside the module
folder, one file per platform: bin/<name>-macos-arm64 and
bin/<name>-win-x64.exe are required, Intel macOS optional, source and the
script that rebuilds every binary under native/.

The guide's rule 3 now says so, "The shape of a module" shows the layout,
and a new "Native helpers" section gives the contract: pick the binary by
platform at runtime, report the feature off when there is none — never
build, never search the machine, never ask the operator to install
anything — one command-line contract in any language per platform, bump
the version when a binary changes, keep them lean, sign the macOS one.
CLAUDE.md carries the one-line version. Rules only: no module changes here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 16:34:34 -04:00
michael 9ca301b4be Merge pull request 'PCO Plan 1.9.0: a Song Key tile, keys as notes, and AbleSet-shaped section chips' (#32) from claude/pco-song-key into main 2026-09-29 14:58:47 -04:00
Waters ChurchandClaude Opus 5 ece2c0a2c3 Centre the NOW tag; the type badge was already fine
Re-measuring the badges properly changed the answer. The earlier reading
that said the type badge sat 0.52px high was measuring the wrong glyphs: it
fed the canvas the element's raw text ("Song", "Media") while the page
renders text-transform: uppercase ("SONG", "MEDIA"), and a lowercase 'd' or
'g' has an ascender and a descender the capitals never draw.

Measured against the text as actually rendered, the type badge is within
0.10px of centred and wants nothing. The NOW tag was the one off, by 0.59px,
the same way the section chips were. So: the badge padding added in the last
commit is reverted, and the NOW tag gets the correction instead.

That line now reads, in px off centre: NOW 0.01, type badge 0.10, key badge
0.16, section chip -0.01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 32180bf299 Centre the text in the song-section chips
The chips looked top-heavy, and measuring said so: the capitals sat 5.12px
below the top of the chip and 4.22px above the bottom. Uppercase text has no
descenders, so the font's descent allowance below the baseline is empty
space the letters never use, while the gap between the ascender line and the
cap height sits above them.

The padding is now a touch shallower on top and deeper below, which puts the
letters themselves in the middle: 4.67px above, 4.68px below. In em, so it
holds wherever the tile's text size is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 06066529bc Keys read as notes on the Plan tile's badge too
The item badge still showed Planning Center's raw "Bb" and "F#m" while the
Song Key tile beside it showed B♭ and F♯m. One module should not spell the
same thing two ways.

The notation moves into key-notation.js and both tiles import it, rather
than each keeping its own copy of the parse: the day one of them changes,
they all should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 4e5b60e82b PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips
**Song Key**, a third entry in the + picker: the key of the item
ProPresenter has live, as large as the tile allows. It is set as a note —
B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed
song under it. All three are tile settings: the colour, the name and the
next key. There is no new connection; it reads the same streamed state the
Plan tile does.

The accidental and the quality sit on the same line as the letter. Raising
the accidental read as two stacked characters at tile sizes, which is worse
than plain at a glance from the back of a room.

When the ProPresenter feed that picks the item goes away the key stays up —
the band still needs it — but faded, and the tile's dot says it is the last
item rather than the current one.

**The section chips** on the Plan tile's current item are rounded squares
now, with the tinted ground and transparent border the AbleSet module gives
its own section pills, so the two read as one family. The chip playing keeps
its own ground so its progress fills against the chip rather than against
the row behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
michael 92b98b6579 Merge pull request 'SPL Meter 0.4.3: a failed capture-tool build must not park the listener forever' (#31) from claude/spl-build-retry into main 2026-09-28 11:31:49 -04:00
michael 58f3e5010a Merge pull request 'SPL Meter 0.4.2: the device picker can build the tool it needs' (#18) from claude/spl-devices-without-binary into main 2026-09-28 11:30:36 -04:00
michael 7cc19ef696 Merge pull request 'Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden' (#33) from claude/timers-video-countdown into main 2026-09-27 11:35:52 -04:00
Waters ChurchandClaude Fable 5 86a3270f1c Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden
Time left on the video ProPresenter is playing — the one number a booth
needs during a bumper or sermon video — was missing: the module only read
the configured timers, and the video countdown lives in the transport
routes. Verified live on ProPresenter 21.4:
  GET /v1/transport/presentation/current → { is_playing, uuid, name,
    artist, audio_only, duration }
  GET /v1/transport/presentation/time    → bare seconds as a sub-second
    float — better than /v1/timer/video_countdown's whole-second string,
    so no quantization phase lock is needed, only an anchor hold (a frame
    is broadcast only when the anchor drifts, not per poll).

The Video card rides the ProPresenter source: transport current on the
slow lane, position on the hot path (skipped while no media is loaded),
running/paused/idle from is_playing and position, the media name (minus
its file extension) as the detail line. It persists as an idle '—'
between videos — a wall tile must not appear and vanish with the media —
and is absent only on a ProPresenter whose transport routes 404.

No looping flag exists anywhere in the API, so looping is inferred: the
same media's position jumping backwards is a wrap, and the flag sticks
until the media changes. Each tile gets a 'Looping videos' switch
(default off): off, a looping background's meaningless time is hidden —
the card sits out of a multi-card tile, and a solo Video tile shows a
quiet 'Looping' card instead of sitting empty. Shown loops never take
the warn/overrun tones: a 15 s background flashing red every wrap would
cry wolf all service.

The picker offers 'Video' as its own tile (solo gear carries the loop
switch); pp-timers-mock gains the transport routes with a 4-minute cycle
(bumper playing → paused → resumed → nothing → looping background).
Verified in the browser across the whole cycle, both tile kinds, and the
per-tile toggle. Selftests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:32:36 -04:00
Waters ChurchandClaude Fable 5 e9d6edc2c8 SPL Meter 0.4.3: a failed capture-tool build must not park the listener forever
Same deadlock the Timers module hit live on AB (fixed there in 1.5.1):
start() skipped scheduling its retry while a build was in flight, and
ensureCaptureBinary's onReady only fires on build SUCCESS. A failed
build therefore left the listener waiting forever on a callback that
never comes — "Building the capture tool…" indefinitely — and a binary
scp'd in from outside (the required path on AB, whose CLT cannot
compile) was never picked up without a module remount.

onReady stays as the fast path; the 30-second retry is now armed in
every not-ready state, so a failed build keeps re-checking and an
externally supplied binary is found within half a minute.

Reproduced pre-fix (build fails at 0s, binary copied in at 3s, still
untouched at 38s) and verified post-fix (retry at 30s spawns it).
spl-capture --selftest unchanged against the IEC 61672 table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:03:57 -04:00
michael b3b7b29007 Merge pull request 'Timers 1.5.1: a failed capture-tool build must not park the listener forever' (#30) from claude/timers-build-retry into main 2026-09-27 10:59:17 -04:00
Waters ChurchandClaude Fable 5 62c34fe80c Timers 1.5.1: a failed capture-tool build must not park the listener forever
Found live on AB: the build failed (its CLT is broken), the compiled
binary was then copied in by hand — and the listener never noticed. It
had seen 'build in flight' and stopped scheduling retries, waiting for
ensureCaptureBinary's onReady, which only fires on SUCCESS. The tile said
'Capture tool building…' indefinitely with a working binary on disk; only
a module remount recovered it.

onReady stays as the fast path; the 30 s retry now stays armed in every
not-ready state, so a failed build keeps re-checking and a binary that
arrives from outside (scp'd to a machine that can't compile) is picked up
within half a minute.

Reproduced the exact shape in a swiftc-less environment: build fails,
binary copied in at 3 s, retry at 30 s spawns it (the capture's own
unknown-device answer proves the real binary ran) — previously a
permanent deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:55:44 -04:00
michael 4c6a2b8f76 Merge pull request 'Shell 1.9.2: a module update keeps per-machine executables' (#29) from claude/shell-keep-executables into main 2026-09-27 10:49:07 -04:00
Waters ChurchandClaude Fable 5 b0defbd56b Shell 1.9.2: a module update keeps per-machine executables
AB updated Timers from the catalog and LTC died: the update replaces the
module folder wholesale, which deleted the compiled ltc-capture binary —
and AB's Xcode CLT cannot rebuild it (the SwiftBridging modulemap defect;
the module's new self-build correctly runs there and fails with exactly
that error). The binary on AB has always been compiled elsewhere and
shipped, so 'update' was guaranteed to break LTC on that machine.

installModule now carries executables (any execute bit) the download does
not include over from the copy being replaced — or from the app's bundled
copy on a first install — preserving path and mode. Executables only, on
purpose: those are the gitignored per-machine build artifacts (ltc-capture,
spl-capture), while stray text files from an old version — code an update
deliberately removed — must not be resurrected. The Timers module's
staleness check still notices a carried binary older than its updated
source and rebuilds behind it where the toolchain works.

Verified end-to-end against the real Gitea catalog: an installed 1.4.9
copy holding a fake executable and a stray .js file, updated to 1.5.0 —
the executable survives with its mode and content, the stray file is gone,
and the log names what was kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:41:41 -04:00
michael 914ff7795b Merge pull request 'Shell 1.9.1: a module update actually runs the updated code' (#28) from claude/shell-module-update-reload into main 2026-09-27 10:30:54 -04:00
Waters ChurchandClaude Fable 5 076e583daf Shell 1.9.1: a module update actually runs the updated code
AB updated the Timers module to 1.5.0 and its new LTC capture-tool
self-build never ran: mountModule re-required the module's server entry,
and Node's require cache handed back the OLD code — the catalog update
had swapped the folder on disk, but the process kept executing what it
loaded at startup until ProdDash itself restarted. The manifest, read
fresh from disk, meanwhile reported the new version, so the admin page
claimed an update that functionally hadn't happened. Every module update
to date has silently depended on a follow-up app restart.

mountModule now drops the module directory's entries from require.cache
before requiring, so every (re)mount runs the files on disk — nested
requires (ltc-listener.js and friends) included. Modules already must
come up clean on re-init per the module guide, so config-change remounts
see the same contract, minus the trap.

Verified with a probe module in a scratch data dir: change a nested
require on disk, PUT config to remount, the route serves the new code
(it served the stale code before the fix). The Timers module re-inits
cleanly across repeated fresh-code remounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:26:38 -04:00
michael 21519dd3da Merge pull request 'Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line' (#27) from claude/timers-capture-install into main 2026-09-27 10:08:25 -04:00
Waters ChurchandClaude Fable 5 8843339f11 Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line
A catalog install ships source only, and a catalog update deletes the
module folder — a previously built ltc-capture binary with it — so every
install or update left LTC dead until someone compiled the tool by hand.
The build also still used the blocking spawnSync that once froze the whole
server (the bug spl-meter fixed in 0.4.0), and only ran when a listener
started: no tool meant no device list, and no device meant no listener.

ensureCaptureBinary() (module scope in ltc-listener.js, the spl-meter
0.4.2 pattern) now builds the tool in the background as part of coming
up: kicked when the LTC source starts and by the admin device picker,
single-flight, built to a temp name and renamed so a killed build can't
leave a truncated binary, and rebuilt behind a still-serving binary when
a module update leaves the source newer. The waiting listener starts
itself the moment the build lands.

Tile-side warnings are now one line — 'Not configured — see Admin',
'Unreachable', 'Capture tool building…' — with the client's source-label
prefix no longer repeated. The full diagnostics (host:port, errno, the
capture tool's own words, build instructions) move to the admin health
line and the log, where the person who can act on them is looking.

Verified: build from the exact catalog-install state (source only) lands
and lists 12 real devices; touching the source triggers an atomic rebuild
while the old binary keeps serving; /ltc/devices kicks the build and
answers with the device list; live server shows terse source messages on
the tile and the detailed story in /api/admin/state health. ltc and phase
selftests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 09:47:05 -04:00
michael 4ed22b361b Merge pull request 'PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile' (#25) from claude/pco-ableset-progress into main 2026-09-27 09:15:56 -04:00
Waters ChurchandClaude Opus 5 aec9c47ed4 PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile
Three things, the first on its own.

**Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and
scrolls every ancestor that can scroll, so bringing the current item into
view dragged the whole dashboard with it whenever the board was taller than
the window. The list's own `scrollTop` is moved instead, measured from
rectangles because the rows' offset parent is the tile rather than the list.

**The current item fills as its song plays.** When an AbleSet module is
installed, configured and playing a song that IS the item on screen, that
item's background fills left to right with the song. The song is tied to a
plan item by the same name matching the live ProPresenter document already
uses, so a bar can never quietly fill the sermon while the band rehearses
something else.

**Its sections slide past, bottom right of that item.** Every section of the
song as a strip, the played ones dimmed, the one playing filling in turn and
kept centred — by moving that strip's own `scrollLeft`, for the same reason
as above. The chips are rebuilt only when the song changes; moving to the
next section just moves the classes, so the strip keeps its place.

Both are switches in the tile's gear, **Song progress** and **Song
sections**, and both draw nothing at all unless there is an AbleSet module
to follow: the server reports `available: false` for no module, a disabled
one, or one with no host, and re-checks every 15 s so installing AbleSet
later lights it up without a restart.

The server follows the AbleSet module over loopback exactly as it follows
ProPresenter's, and republishes a small slice — song, sections, playhead in
beats with a stamp — as its own `ableset` SSE event. Its own event because
it moves five times a second and must never drag the plan (items, teams)
onto the wire with it; the tile carries the playhead forward from the tempo
between packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 09:14:19 -04:00
michael 5f60cd563c Merge pull request 'Timers 1.4.2: ProPresenter digits no longer flick back a second' (#26) from claude/timers-second-phase into main 2026-09-27 09:05:46 -04:00
michael de32046553 Merge pull request 'AbleSet 1.0.0: follow AbleSet's setlist over OSC' (#17) from claude/proddash-ableset-module-6c2556 into main 2026-09-27 09:02:23 -04:00
Waters ChurchandClaude Opus 5.5 c1d9c59325 Merge origin/main into claude/proddash-ableset-module-6c2556
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:01:35 -04:00
Waters ChurchandClaude Fable 5 2f4c357bf5 Timers 1.4.2: ProPresenter digits no longer flick back a second
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 08:46:50 -04:00
Waters ChurchandClaude Fable 5.1 b5c539ba05 Release builds: Intel disk images, a Windows bundle, and where to download them
launcher/macos/build.sh takes ARCH=x86_64 for Intel Macs — the Swift target
and the embedded Node follow (nodejs.org calls that one darwin-x64). The
default stays arm64.

launcher/windows/build.sh is new: the same payload plus Node for Windows,
checksum-verified against nodejs.org, and ProdDash.bat, which keeps the
server running and starts it again on exit code 75 — the admin page's update
restart — the way the macOS launcher does. No Windows launcher app yet.

The README's Run it section now points at the Releases page and the
ProdDash-releases repo that holds the files, one line each for Apple
silicon, Intel and Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:53:19 -04:00
michael 7a736b3db1 Merge pull request 'PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos' (#23) from claude/pco-teams-tile into main 2026-09-24 15:27:59 -04:00
Waters ChurchandClaude Opus 5 6c0aee5abf Teams tile: one Colors group, no Positions switches
Colors and Status colors were two groups asking the same question, so they
are one: team header, the tints and the three status colours together.

The Positions switches go entirely — the team switches already decide what
a tile shows, and "Group by position" still arranges what is left. With
them go GET /positions and positionOptions().

Two rules the tile was breaking on the way past: a gear group may carry no
`help` (rule 5), and only hide-toggles belong in Show (rule 8) — so the
group blurbs are gone and the Columns picker sits loose at the top beside
the team switches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:15:34 -04:00
Waters ChurchandClaude Opus 5 6d0f8828c1 PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos
The module now offers two tiles in the + picker, Plan and Teams, sharing
the one Planning Center connection and the one poll.

Teams draws the current plan's team and position assignments: each team as
a band of its people, with the person's photo, their position and a dot for
confirmed / unconfirmed / declined. Photos are proxied and cached by the
module (GET /team-avatar/<planPersonId>) so no browser ever talks to
Planning Center; a person with no photo gets their initials.

Everything shown is the tile's own choice, so two tiles can differ:
- Teams and Positions — a switch per team and per position, all on until
  someone flips one (GET /teams, GET /positions feed the lists)
- Colours — a picker for the team headers, or Tint teams to give each its
  own, optionally carried into the rows, plus the header fade and pickers
  for the three statuses
- Show — twelve switches (top bar, team headers and counts, avatars,
  positions, group by position, status, notes, declined and unconfirmed
  people, teams nobody is on, columns)

Server: team_members?include=team,person rides a slow lane (re-read on a
plan change, else at most once a minute) and can never fail the plan read —
a lost roster keeps the last good one and says so. The service type's own
team list is read too, cached ten minutes: Planning Center only returns a
team once somebody is scheduled on it, so that list is the only way to show
a team nobody has signed up for.

No shell changes: picker entries carrying their own instanceSchema, the
colour field and multiselects all already exist.

tools/pco-mock.js grows team_members, the service type's teams and stand-in
avatar images, including a declined person, people with no photo and a team
nobody is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:42:57 -04:00
christian 8ca88ba10b Merge pull request 'Resi Cues: the countdown to the next cue, on the board and as a timer' (#22) from claude/resi-stage-cues into main 2026-09-24 12:19:49 -04:00
christianandClaude Opus 5 a8d1b8e159 Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

`modules/resi-cues/` is an ordinary module. Its tile is one number as large as
the tile allows — the countdown to the next Resi cue, the cue's name above it,
the playhead and the cue just passed underneath, and the whole cue list behind
a tile switch. It also declares `"provides": ["timers"]` and serves `/timers`
and `/timers/stream`, so the countdown and the playhead turn up among the
ProPresenter timers in the Timers module without either side knowing about
the other.

The countdown ticks in the browser. The server sends the playhead with the
instant it was true and whether playback is running, and the tile adds the
elapsed time since; the Timers module extrapolates from `startedAt` the same
way. So a snapshot only has to be sent when something structural changes — a
new cue, a pause, a seek — rather than at 1 Hz to every open dashboard.

`resi.js` holds the part worth reading twice. Resi republishes a decoder's
position only every few seconds, on an irregular cadence, and each reading is
already stale when it appears; a countdown built straight from it sits seconds
behind and lurches. So every reading is kept as media time minus wall time and
the largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip cannot drag backwards. A reading outside the
link's measured noise floor counts as a seek: drop the lock, snap, and re-read
the cue list at once instead of on its timer. Playback is only called paused
once the playhead has been still for longer than Resi's own cadence, because a
fixed timeout mistakes a slow update for a stop and freezes the countdown
mid-service.

Driving the ProPresenter timer on the stage screen is Companion's job now. It
is already a service on that machine, so nothing new has to be kept alive, and
this module no longer has to reach ProPresenter at all — it only has to be
right about Resi, which is the hard part.

`resi.test.js` drives the estimator on a fake clock against nine simulated
Resi profiles — cadences of one to twelve seconds, staleness to three,
irregular timing, variable round trips — covering lock-on, a fired cue, a seek
backwards, a pause, and a pause longer than the offset window. A punctual
fixture proves nothing here: against one, three separate breakages of the
estimator go unnoticed. `server.test.js` runs the real server entry against a
canned Resi account and checks what the two consumers actually read, including
the `/timers` shape field by field. Every mutation tried against either turns
them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:08:55 -04:00
christianandClaude Opus 5 82c552955d Resi cue countdowns reach the stage screen without Companion
The ask was a Stream Deck button showing the time until the next Resi cue.
What it turned into is simpler: ProPresenter already renders countdowns, and
it already has an HTTP API, so nothing has to sit in between except something
that knows when the next cue is.

`resi_to_propresenter.py` watches a decoder's playhead and keeps a
ProPresenter countdown timer armed with the seconds until its next cue, with
the cue's name as a stage message beside it. Both halves of that come from
Resi's cloud rather than the decoder — the decoder reports its position
upstream and `central.resi.io` hands it back — so there is nothing to install
at the decoder end and nothing polling it every second.

The playhead is estimated rather than repeated. Resi republishes `position`
only every few seconds, irregularly, and each reading is already stale when
it appears; sent straight through, the countdown sat three seconds behind and
lurched. So each reading is kept as media time minus wall time and the
largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip can no longer drag backwards. A reading
outside the link's measured noise floor counts as a seek: drop the lock, snap,
and re-read the cue list immediately instead of on its timer. Playback is
only called paused once the playhead has been still for longer than Resi's own
cadence, because a fixed timeout mistakes a slow update for a stop and freezes
the countdown mid-service.

This is not a tile. There is no `module.json`, so `readManifests` skips the
folder and it never reaches the + picker; it lives under `modules/` because
that is where the production tooling lives. That is also why it carries a
README when no real module does — there is no *Included modules* entry it
could have instead — and the repo README says so where the module list ends.

`install-agent.command` sets up a LaunchAgent so it runs from login and
restarts if it stops. It deliberately runs a copy in Application Support: a
login agent cannot read `~/Documents` without Full Disk Access, and macOS
answers "Operation not permitted" rather than asking. Settings live there too,
outside the checkout, the same way module config does. The Resi password is
read from the Keychain and never written to disk; with none set the agent
says so once and waits rather than relaunching in a loop.

`test_playhead.py` drives the estimator on a fake clock against nine
simulated Resi profiles — update cadences of one to twelve seconds, staleness
to three, irregular timing, variable round trips — and six of seven deliberate
breakages of the estimator turn it red. `test_end_to_end.py` runs the real
loop against a real ProPresenter with a simulated decoder, and has: it armed
for the first cue, counted down in step, rolled over to the second and stopped
when the cues ran out. Neither covers the live Resi calls, which need real
credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:35:40 -04:00
christian ea3a920d5e Merge pull request 'Service Times 1.0.0: every campus's services, and what production can't use' (#20) from claude/service-times-module into main 2026-09-24 11:34:38 -04:00
Waters ChurchandClaude Opus 5.5 9192eed318 AbleSet 1.0.1: a remounted tile asks for the state instead of waiting
Every gear change remounts the tile, and the remounted tile joins the
page's shared stream mid-flow, so it heard nothing until AbleSet next
changed — with the transport stopped, never. Tiles went blank until a
reload. The tile now fetches /state when it starts and whenever the
stream (re)opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:07:13 -04:00
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00
Waters ChurchandClaude Opus 5.5 41fd76817c AbleSet 1.0.0: follow AbleSet's setlist over OSC
A new module that subscribes to AbleSet's OSC API (UDP 39051) and streams
what it is playing to ten tiles: Now Playing (song, tempo, meter, duration,
time left, section, progress bar, next song, songs left), Song Sections
(a sliding strip, or a lottery wheel from the title bar), Setlist,
Transport, Tempo, Song Time, Set Time, Section, Next Song and Status, each
coloured by its state. The song and set countdowns are offered to the
Timers module.

A heartbeat watchdog resubscribes when AbleSet goes quiet (a restarted
AbleSet forgets its subscribers). Time left falls back to the song length
and playhead when AbleSet stops sending it (Live disconnected).

tools/ableset-mock.js plays a setlist over the same API for testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:24:19 -04:00
50 changed files with 7262 additions and 195 deletions
+2 -3
View File
@@ -14,6 +14,5 @@ modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
launcher/windows/build/
launcher/windows/.cache/
+9 -2
View File
@@ -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/<name>-macos-arm64`, `bin/<name>-win-x64.exe`, source and
build script under `native/`); nothing is ever compiled on the machine
running ProdDash, and a missing binary means the feature reports itself
off, not a broken module. Declare `"proddash": ">=x.y.z"`
and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side).
@@ -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)
+93 -13
View File
@@ -30,11 +30,23 @@ and a clear degraded state, then recover on their own.
## Run it
Ready-made builds are on the
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
the files themselves live in
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
bundle for 64-bit Windows. Every build updates itself afterwards from the
admin page, so the download only has to be recent, not current.
### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
**ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed and nothing can be missing. It lives in the
nothing else has to be installed. It carries no modules — open **Admin →
Available modules**, tick the ones this machine needs and press **Install
selected**; each updates on its own from then on. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the
@@ -43,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
zeroes.
Build the disk image from a checkout with
[launcher/macos](launcher/macos)/`build.sh`:
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
for Intel):
```bash
cd launcher/macos && ./build.sh --dmg
```
### Windows — the bundle
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
to — `C:\ProdDash`, say, not Program Files, because updates write into the
folder — and double-click **ProdDash.bat**. The window it opens is the
dashboard running: it stays open, and it starts the server again when the
admin page applies an update. Allow `node.exe` on private networks when the
firewall asks, so other screens can reach it. Settings and layouts live in
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
menu-bar-style launcher for Windows yet; the bundle is built with
[launcher/windows](launcher/windows)/`build.sh`.
### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer.
@@ -179,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
@@ -303,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
@@ -323,7 +353,11 @@ below. Nothing is installed without someone clicking.
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **PCO Plan** — the order of service from Planning Center Services: every
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
Key** (the key of the item on screen).
**Plan** shows every
plan item with its scheduled start time, length, type, song key,
description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims
@@ -342,6 +376,36 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail.
With an **AbleSet** module installed and configured, the Plan tile also
follows the band: while AbleSet plays a song that is the item on screen,
that item's background fills as the song goes, and its sections slide past
in a strip at the bottom right of the item, the one playing filling in
turn. Both are switches in the tile's gear (**Song progress**, **Song
sections**), and both are simply absent when there is no AbleSet module, it
is switched off, or it has no host — nothing to turn off and nothing to
explain.
**Song Key** is one value, as large as the tile allows: the key of the item
ProPresenter has live, with the song's name and the next key under it — both
switches, as is the colour. When the feed that picks the item is gone the key
stays up, faded, and the tile's dot says so rather than pretending to be
current.
Keys are written as notes wherever the module shows one: Planning Center's
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
Song Key tile alike.
**Teams** shows the same plan's team and position assignments — each team
as a band of its people, with their photo (fetched through ProdDash, never
by the browser; initials when Planning Center has none) and a dot for
confirmed, unconfirmed or declined. Its gear menu picks which teams and
which positions appear, groups people under their position or lists them
plainly, and sets the colours: one for the team headers, or **Tint teams**
to give each team its own, optionally carried into the rows. Teams nobody
has signed up for are hidden until you ask for them — that list comes from
the service type, since Planning Center only returns a team once somebody
is on it.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
@@ -365,6 +429,22 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Resi Cues** — how long until the next Resi cue point, for a campus
running video from another site. The tile is one number, as large as it
will go: the countdown to the next cue, its name above it, and the playhead
and the cue just passed underneath. It also offers that countdown and the
playhead to the **Timers** module, so they can sit among the ProPresenter
timers instead of in a tile of their own.
Both the cue list and the decoder's position come from Resi's cloud rather
than the decoder — the decoder reports upstream and central.resi.io hands it
back — so nothing is installed at the decoder end. Resi republishes a
position only every few seconds and each reading is already stale, so the
playhead is estimated between readings rather than repeated; a countdown
built straight from Resi's number sits seconds behind and moves in lurches.
Admin config: Resi login and password, which decoder to follow (the list is
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
+84 -8
View File
@@ -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/<id>/
├── module.json manifest (required)
├── client.js ES module, client entry (required)
├── server.js server entry (optional — only if you need server code)
├── bin/ native helpers, prebuilt per platform (only if you need one)
│ ├── <name>-macos-arm64
│ └── <name>-win-x64.exe
├── native/ their source and the script that builds every bin/ file
└── ... assets (css, images…) served statically at /modules/<id>/
```
**Installation is exactly this:** in the admin page, open **Available
modules** and press **Install** — the module is downloaded from the ProdDash
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
immediately. Developing locally? Drop the folder into `modules/` (bundled
with the checkout) or into the data directory's `modules/` and restart. No
shell code changes, ever. Modules bundled with a checkout and modules
installed from the admin page live side by side; when both have the same id
the newer version runs. **Uninstall** in the admin page removes an installed
module's files (a bundled one is hidden instead, since its folder belongs to
the app); its settings are kept for a later reinstall.
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
with **no modules at all**: every one of them arrives this way, and the
shell's own update never brings one along, so what is in git is exactly what
runs on a booth. Developing locally? A git checkout runs whatever is in its
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
the data directory's `modules/` and restart. No shell code changes, ever. In
a checkout the two live side by side; when both have the same id the newer
version runs. **Uninstall** in the admin page removes an installed module's
files (in a checkout, the tree's copy is hidden instead, since that folder
belongs to the repo); its settings are kept for a later reinstall.
### Native helpers
Some features need a program JavaScript cannot be: reading an audio input,
decoding timecode, measuring sound. Rule 3 says the module owns that program;
this section says how it ships: **already built, for every platform it runs
on, inside the module folder.** The machine running ProdDash is a packaged
app or the Windows bundle plus modules from the admin page's catalog. It has
no compiler and never will — the catalog delivers exactly the folder that is
in the repo, so the binaries live in git next to the code.
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
macOS is welcome but optional. One file per platform in `bin/`, named for
what it is:
| Platform | File |
| --- | --- |
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
and `process.arch`; when there is no file for this machine, the feature
says so — in `health()` and in the tile's `setStatus` — and everything else
in the module keeps working. Never try to build it, never look for a copy
elsewhere on the machine, never tell the operator to install anything.
```js
const HELPERS = {
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
win32: { x64: 'ltc-capture-win-x64.exe' },
};
const name = HELPERS[process.platform]?.[process.arch];
const helper = name ? path.join(__dirname, 'bin', name) : '';
if (!helper || !fs.existsSync(helper)) {
// Timecode is off on this platform. Say it once, keep the clock running.
}
```
- **One contract, any language.** Every platform's binary speaks the same
command line and output format, so `server.js` has one code path. What is
behind it is your choice per platform — Swift against CoreAudio on macOS,
C#, C or Rust on Windows — the module does not care.
- **Source and build beside it.** Keep the source under `native/` with one
script that produces every `bin/` file from a checkout, so any binary can be
rebuilt from what is committed. A helper that cannot be rebuilt does not
ship.
- **Bump the module version whenever a binary changes**, like any other
file — that is how installs learn about it.
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
files the app writes carry no download mark, so SmartScreen never sees
them. Permissions come from the launcher (see "Permissions macOS has to
grant"), not from the binary.
A helper that ends up existing for one platform only is an exception made
on purpose, not a default: say so in the module's README and in the health
line, so a Windows booth knows why the feature is off.
## module.json
@@ -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:
+5 -3
View File
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
```bash
./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
@@ -68,9 +69,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
against nodejs.org's published checksums either way.
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
is the app's icon. A module's native helper is compiled during the build
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
ProdDash never needs a compiler.
is the app's icon. The app carries no modules: they are installed from the
admin page's catalog into the data directory, and a module's native helper
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
never needs a compiler.
## Signing
+15 -25
View File
@@ -1,6 +1,8 @@
#!/bin/zsh
# Build ProdDash.app — the whole application: launcher, server and its own
# Node runtime, with nothing to install first.
# Node runtime, with nothing to install first. It carries no modules: those
# come from the admin page's catalog into the data directory, and a module's
# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md).
#
# ./build.sh build/ProdDash.app, self-contained
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
@@ -11,6 +13,8 @@
# ./build.sh --dev launcher only — no Node, no server payload, so
# it runs against the checkout. Seconds, not
# minutes, while working on the launcher itself.
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
# silicon); the Node it embeds follows.
#
# Signing: a "Developer ID Application" certificate is used when one is
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
@@ -27,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
# The Node the app ships. Bump it here; the download is checksum-verified
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
NODE_VERSION="v24.21.0"
ARCH="arm64"
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
DEPLOYMENT_TARGET="13.0"
case "$ARCH" in
arm64) NODE_ARCH="arm64" ;;
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
esac
REPO_ROOT="../.."
OUT_DIR="build"
@@ -106,7 +115,7 @@ fi
# ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
@@ -125,25 +134,16 @@ if [[ $DEV -eq 0 ]]; then
echo " embedding Node $NODE_VERSION"
mkdir -p "$CONTENTS/Resources/node/bin"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
echo " embedding ProdDash $VERSION"
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
mkdir -p "$PAYLOAD/config"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
find "$PAYLOAD" -name '.DS_Store' -delete
# A module's native helper is built here, so the Mac running ProdDash needs
# no compiler: <name>.swift beside a module's files becomes <name>.
for source in "$PAYLOAD"/modules/*/*.swift; do
[[ -e "$source" ]] || continue
helper="${source%.swift}"
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
done
fi
# ── signing ──────────────────────────────────────────────────────────────
@@ -165,16 +165,6 @@ fi
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
fi
if [[ $DEV -eq 0 ]]; then
for helper in "$PAYLOAD"/modules/*/*; do
# A compiled helper is the extensionless executable beside a module's
# sources — test the name, not the path, which has dots all over it.
name=$(basename "$helper")
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
echo " signing $name"
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
done
fi
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep --strict "$APP" && echo " signature verifies"
+121
View File
@@ -0,0 +1,121 @@
#!/bin/zsh
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
# its own Node runtime and a start script, so nothing has to be installed on
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
#
# ./build.sh build/ProdDash-<version>-win-x64.zip
#
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
# the server running and starts it again when the admin page applies an
# update (the server exits with code 75 to ask for that).
set -e -u
cd "$(dirname "$0")"
APP_NAME="ProdDash"
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
NODE_VERSION="v24.21.0"
NODE_DIST="node-$NODE_VERSION-win-x64"
REPO_ROOT="../.."
OUT_DIR="build"
CACHE_DIR=".cache"
for tool in curl shasum unzip zip; do
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
done
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
NAME="$APP_NAME-$VERSION-win-x64"
STAGE="$OUT_DIR/$NAME"
echo "ProdDash $VERSION — building the Windows bundle"
rm -rf "$OUT_DIR"
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
# ── Node for Windows ─────────────────────────────────────────────────────
ZIPFILE="$NODE_DIST.zip"
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
fi
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
# This binary ships under ProdDash's name — check it every build.
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
exit 1
fi
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
# ── ProdDash ─────────────────────────────────────────────────────────────
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
cp -R "$REPO_ROOT/public" "$STAGE/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
find "$STAGE" -name '.DS_Store' -delete
# ── the start script and the read-me, with Windows line endings ──────────
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
crlf "$STAGE/ProdDash.bat" <<'BAT'
@echo off
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
title ProdDash
cd /d "%~dp0"
rem The server exits with code 75 after applying an update from the admin page; start it again.
set PRODDASH_LAUNCHER=1
:run
"%~dp0node\node.exe" server.js
set CODE=%ERRORLEVEL%
if %CODE% EQU 75 goto run
echo.
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
pause >nul
BAT
crlf "$STAGE/README.txt" <<README
ProdDash $VERSION for Windows (64-bit)
=======================================
Nothing to install. This folder holds the dashboard and its own copy of Node.
1. Move this folder somewhere you can write to, for example C:\ProdDash
(not Program Files: updates write into this folder).
2. Double-click ProdDash.bat. A window opens and stays open while the
dashboard runs; closing it stops the dashboard.
- If Windows warns about the file, choose More info, then Run anyway.
- When Windows Defender Firewall asks about node.exe, allow it on
private networks so other screens can reach the dashboard.
3. Open http://localhost:24500 on this PC. Other devices on the network use
http://<this PC's IP address>:24500. Settings are on the admin page,
http://localhost:24500/admin.
4. The bundle ships without modules. On the admin page, tick the ones this
PC needs under Available modules and press Install selected; each one
updates on its own from then on.
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
so unzipping a new version or updating never loses them.
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
shortcut to ProdDash.bat in the folder that opens.
Updates: the admin page's Update now fetches the new version into this
folder and restarts the dashboard; the window stays open. A copy of the
version it replaced is kept for a short while in case the new one will not
start.
README
# ── zip ──────────────────────────────────────────────────────────────────
echo " zipping"
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
+727
View File
@@ -0,0 +1,727 @@
/* AbleSet — client part.
One factory draws every AbleSet tile; `moduleApi.variant` says which one
(the server's tiles() list). All of them read the same stream: `state`
whenever something changes, `pos` five times a second while playing.
Between packets the playhead and the countdowns are extrapolated from the
tempo and the clock, so bars glide instead of stepping. */
const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status'];
/* AbleSet names its colours after Live's palette; these are close enough to
read as the same colour on a dark tile. Unknown names draw no colour. */
const NAMED_COLORS = {
red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399',
mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6',
indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6',
rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5',
};
const ICON_STRIP = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */
function fmt(sec) {
if (sec == null || !Number.isFinite(sec)) return '--:--';
const s = Math.max(0, Math.floor(sec + 1e-6));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`;
}
function fmtBpm(t) {
if (t == null) return '--';
return (Math.round(t * 100) / 100).toString();
}
function colorFor(value) {
if (!value) return null;
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
return NAMED_COLORS[String(value).toLowerCase()] || null;
}
export default function create({ root, moduleApi }) {
const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant
: moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing
const prefs = () => moduleApi.instanceSettings || {};
const on = (k) => prefs()[k] !== false;
let state = null;
let pos = { beats: null, remainingSong: null, remainingSet: null };
let posAt = 0;
let stream = null;
let raf = 0;
let resizeObserver = null;
let tickTimer = null;
/* ── moving values, extrapolated to this frame ─────────────────── */
const moving = () => Boolean(state && state.reachable && state.playing);
const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0);
function beatsNow() {
if (pos.beats == null) return null;
const t = state?.tempo;
return t ? pos.beats + dt() * (t / 60) : pos.beats;
}
const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt()));
const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt()));
function fraction(start, end, b) {
if (start == null || end == null || b == null || end <= start) return null;
return Math.min(1, Math.max(0, (b - start) / (end - start)));
}
function songsLeft() {
if (!state?.song || state.song.index == null) return null;
return Math.max(0, state.setlist.songs.length - state.song.index - 1);
}
/** Beats per bar from the time signature (Live counts quarter notes). */
function beatsPerBar() {
const ts = state?.timeSig;
return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4;
}
/** Bar and beat within the song, 1-based. */
function barBeat() {
const b = beatsNow();
const start = state?.song?.start ?? 0;
if (b == null) return null;
const rel = Math.max(0, b - start);
const bpb = beatsPerBar();
const ts = state?.timeSig || [4, 4];
const beatLen = 4 / (ts[1] || 4);
const bar = Math.floor(rel / bpb) + 1;
const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1;
return { bar, beat, of: ts[0] || 4 };
}
function tone(sec) {
if (sec == null || !moving()) return '';
const warn = Number(prefs().warnAt ?? 30);
const alert = Number(prefs().alertAt ?? 10);
if (sec <= alert) return 'alert';
if (sec <= warn) return 'warn';
return '';
}
/* ── fitting one-line text to its box ──────────────────────────── */
/** Shrink a nowrap element until it fits the width its parent gives it. */
function fit(el) {
if (!el || !el.isConnected) return;
el.style.fontSize = '';
const max = parseFloat(getComputedStyle(el).fontSize) || 16;
const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth;
let size = max;
for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) {
size = Math.max(9, size * (avail / el.scrollWidth) * 0.98);
el.style.fontSize = `${size}px`;
}
}
const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit);
/** Set text, refitting only when it changed. */
function text(el, value) {
if (!el) return;
const v = String(value ?? '');
if (el.textContent === v) return;
el.textContent = v;
if (el.hasAttribute('data-fit')) fit(el);
}
function show(el, visible) {
if (el) el.hidden = !visible;
}
/* ── skeleton per view ─────────────────────────────────────────── */
root.innerHTML = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
const shell = root.firstElementChild;
const body = shell.querySelector('.as-body');
const offlineEl = shell.querySelector('.as-offline');
const $ = (sel) => body.querySelector(sel);
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
const SKELETONS = {
now: `
<div class="as-title-box"><div class="as-title" data-fit></div></div>
<div class="as-badges">
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
</div>
${BAR}
<div class="as-next">
<div class="as-next-label">Next:</div>
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
<div class="as-left"></div>
</div>`,
sections: `
<div class="as-song-name" data-fit></div>
<div class="as-viewport"><div class="as-track"></div></div>`,
setlist: `
<div class="as-set-head"><span class="as-set-name"></span></div>
<div class="as-list"></div>
<div class="as-set-foot"></div>`,
transport: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
tempo: `
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
<div class="as-sub"><span class="as-meter"></span></div>
<div class="as-beats"></div>`,
'song-time': `
<div class="as-caption" data-fit></div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub as-sub-time"></div>
${BAR}`,
'set-time': `
<div class="as-caption" data-fit>Set</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
section: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
<div class="as-sect-bar"><div></div></div>`,
'next-song': `
<div class="as-caption">Next</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-dur"></span></div>
<div class="as-queued" hidden></div>`,
status: `<div class="as-rows"></div>`,
};
body.innerHTML = SKELETONS[view] || '';
/* ── shared pieces ─────────────────────────────────────────────── */
/** The three-part song bar: played sections, this section, the playhead. */
function paintBar(bar) {
if (!bar) return;
const s = state?.song;
const sect = state?.section;
const b = beatsNow();
const len = s && s.end > s.start ? s.end - s.start : 0;
const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0);
const sStart = sect && sect.start != null ? at(sect.start) : 0;
const sEnd = sect && sect.end != null ? at(sect.end) : 0;
const head = b != null ? at(b) : 0;
bar.style.setProperty('--done', `${sStart}%`);
bar.style.setProperty('--sect-l', `${sStart}%`);
bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`);
const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0;
bar.style.setProperty('--played', `${inSect}%`);
}
function songTint(el, hex) {
if (hex) el.style.setProperty('--song', hex);
else el.style.removeProperty('--song');
el.classList.toggle('has-song-color', Boolean(hex));
}
/* ── NOW PLAYING ───────────────────────────────────────────────── */
const nowView = {
render() {
const s = state;
const song = s.song;
text($('.as-title'), song ? song.name : s.setlist.name || 'No song');
shell.classList.toggle('is-playing', moving());
songTint(shell, on('songColors') && song ? colorFor(song.color) : null);
text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : '');
text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : '');
const sect = s.section;
text($('.b-section'), sect ? sect.name : '');
const sc = on('songColors') && sect ? colorFor(sect.color) : null;
$('.b-section').style.color = sc || '';
show($('.b-tempo'), on('showTempo') && s.tempo != null);
show($('.b-meter'), on('showMeter') && Boolean(s.timeSig));
show($('.b-dur'), on('showDuration') && song?.duration != null);
show($('.b-clock'), on('showClock') && pos.remainingSong != null);
show($('.b-section'), on('showSection') && Boolean(sect?.name));
show($('.as-bar'), on('showProgress') && Boolean(song));
const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong;
text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:');
$('.as-next').classList.toggle('is-queued', s.queued?.type === 'song');
text($('.as-next-title'), next ? next.name : 'End of set');
show($('.as-next-label'), on('showNext'));
show($('.as-next-box'), on('showNext'));
show($('.as-left'), on('showLeft') && songsLeft() != null);
this.frame();
},
frame() {
const left = songLeft();
const clockMode = prefs().clock || 'remaining';
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const clock = clockMode === 'elapsed' ? fmt(elapsed)
: clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`;
const c = $('.b-clock');
text(c, clock);
c.dataset.tone = tone(left);
paintBar($('.as-bar'));
const n = songsLeft();
if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`);
},
};
/* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */
const WHEEL_STEP = 30; // degrees between wheel faces
let sectionKey = '';
let wheelButton = null;
const sectionsView = {
style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; },
build() {
const track = $('.as-track');
const list = state.sections || [];
const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`;
if (key === sectionKey && track.children.length === list.length) return;
sectionKey = key;
track.innerHTML = list.map((x, i) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
this.jump();
},
/** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */
jump() {
const track = $('.as-track');
track.classList.add('no-anim');
requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim')));
},
render() {
const wheel = this.style() === 'wheel';
shell.classList.toggle('is-wheel', wheel);
shell.classList.toggle('is-playing', moving());
text($('.as-song-name'), state.song ? state.song.name : '');
show($('.as-song-name'), on('showSong') && Boolean(state.song));
this.build();
const cur = state.section?.index ?? -1;
const nxt = state.nextSection?.index ?? -1;
const pills = $('.as-track').children;
for (const el of pills) {
const i = Number(el.dataset.i);
if (Number.isNaN(i)) continue;
el.classList.toggle('is-past', i < cur);
el.classList.toggle('is-current', i === cur);
el.classList.toggle('is-next', i === nxt);
const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null;
if (c) el.style.setProperty('--sect', c);
else el.style.removeProperty('--sect');
el.classList.toggle('has-color', Boolean(c));
}
this.place();
this.frame();
},
place() {
const track = $('.as-track');
const vp = $('.as-viewport');
const cur = Math.max(0, state.section?.index ?? 0);
const pills = [...track.children];
if (this.style() === 'wheel') {
const h = vp.clientHeight / 3.4;
const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180));
track.style.setProperty('--face-h', `${h}px`);
pills.forEach((el, i) => {
const d = i - cur;
el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`;
el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3);
});
track.style.transform = `translateZ(${-r}px)`;
} else {
pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; });
const el = pills[cur] || pills[0];
if (!el) return;
const center = el.offsetLeft + el.offsetWidth / 2;
track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`;
}
},
frame() {
const cur = state.section?.index ?? -1;
const el = $('.as-track').children[cur];
if (!el) return;
const f = fraction(state.section.start, state.section.end, beatsNow());
el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%');
},
toggleStyle() {
const next = this.style() === 'wheel' ? 'strip' : 'wheel';
moduleApi.saveInstanceSettings({ style: next });
this.syncButton();
this.jump();
if (state) this.render();
},
syncButton() {
if (!wheelButton) return;
const wheel = this.style() === 'wheel';
wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL;
wheelButton.title = wheel ? 'Strip view' : 'Wheel view';
},
};
/* ── SETLIST ───────────────────────────────────────────────────── */
let listKey = '';
let lastScrolled = -2;
const setlistView = {
render() {
const s = state;
text($('.as-set-name'), s.setlist.name || 'Setlist');
const list = $('.as-list');
const songs = s.setlist.songs;
const key = JSON.stringify([songs, on('showDurations'), on('songColors')]);
if (key !== listKey) {
listKey = key;
lastScrolled = -2;
list.innerHTML = songs.map((x, i) => {
const c = on('songColors') ? colorFor(x.color) : null;
return `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
<span class="as-row-fill"></span>
${c ? '<span class="as-swatch"></span>' : ''}
<span class="as-row-name">${esc(x.name)}</span>
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
</div>`;
}).join('') || '<div class="as-empty">No setlist</div>';
}
const cur = s.song?.index ?? -1;
const nxt = s.nextSong?.index ?? -1;
const q = s.queued?.type === 'song' ? s.queued.songIndex : -1;
for (const row of list.children) {
const i = Number(row.dataset.i);
if (Number.isNaN(i)) continue;
row.classList.toggle('is-past', i < cur);
row.classList.toggle('is-current', i === cur);
row.classList.toggle('is-next', i === nxt && q < 0);
row.classList.toggle('is-queued', i === q);
row.hidden = !on('showPlayed') && i < cur;
}
shell.classList.toggle('is-playing', moving());
if (cur !== lastScrolled && list.children[cur]) {
lastScrolled = cur;
const row = list.children[cur];
list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' });
}
show($('.as-set-foot'), on('showTotal'));
this.frame();
},
frame() {
const row = $('.as-list').children[state.song?.index ?? -1];
if (row && state.song) {
const f = fraction(state.song.start, state.song.end, beatsNow());
row.style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
const n = songsLeft();
text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`);
},
};
/* ── TRANSPORT ─────────────────────────────────────────────────── */
const transportView = {
render() {
const s = state;
let word = 'Stopped';
let t = 'muted';
if (s.loading) { word = 'Loading'; t = 'warn'; }
else if (!s.live) { word = 'No Live'; t = 'alert'; }
else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; }
else if (s.countingIn) { word = 'Count-in'; t = 'warn'; }
else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; }
else if (s.playing) { word = 'Playing'; t = 'ok'; }
else if (s.paused) { word = 'Paused'; t = 'warn'; }
text($('.as-big'), word);
shell.dataset.tone = t;
const loop = s.sectionLoop?.mode
? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}`
: s.loop.inSection ? 'Section loop' : '';
text($('.as-loop'), loop);
show($('.as-loop'), on('showLoop') && Boolean(loop));
show($('.as-pos'), on('showPosition'));
this.frame();
},
frame() {
const bb = barBeat();
text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : '');
},
};
/* ── TEMPO ─────────────────────────────────────────────────────── */
let beatCount = 0;
const tempoView = {
render() {
const s = state;
text($('.as-bpm'), fmtBpm(s.tempo));
fit($('.as-big'));
text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
show($('.as-meter'), on('showMeter'));
const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4));
const beats = $('.as-beats');
if (beatCount !== n) {
beatCount = n;
beats.innerHTML = Array.from({ length: n }, (_, i) => `<i class="${i === 0 ? 'is-down' : ''}"></i>`).join('');
}
show(beats, on('showBeats'));
shell.classList.toggle('is-playing', moving());
this.frame();
},
frame() {
const bb = moving() ? barBeat() : null;
const lights = $('.as-beats').children;
for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1);
},
};
/* ── SONG TIME ─────────────────────────────────────────────────── */
const songTimeView = {
render() {
const song = state.song;
text($('.as-caption'), song ? song.name : 'Song');
show($('.as-caption'), on('showSong'));
show($('.as-bar'), on('showProgress') && Boolean(song));
this.frame();
},
frame() {
const left = songLeft();
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const mode = prefs().clock || 'remaining';
text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`);
text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : '');
shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted');
paintBar($('.as-bar'));
},
};
/* ── SET TIME ──────────────────────────────────────────────────── */
const setTimeView = {
render() {
text($('.as-caption'), state.setlist.name || 'Set');
show($('.as-left-count'), on('showLeft'));
show($('.as-end'), on('showEnd'));
shell.dataset.tone = moving() ? 'ok' : 'muted';
this.frame();
},
frame() {
const left = setLeft();
text($('.as-big'), fmt(left));
const n = songsLeft();
text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`);
const end = left != null ? new Date(Date.now() + left * 1000) : null;
text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : '');
},
};
/* ── SECTION ───────────────────────────────────────────────────── */
const sectionView = {
render() {
const sect = state.section;
text($('.as-big'), sect ? sect.name : '—');
const c = on('sectionColors') && sect ? colorFor(sect.color) : null;
songTint(shell, c);
const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name;
text($('.as-next-sect'), nx ? `→ ${nx}` : '');
show($('.as-next-sect'), on('showNext') && Boolean(nx));
show($('.as-count'), on('showCount'));
show($('.as-sect-bar'), on('showCount'));
this.frame();
},
frame() {
const sect = state.section;
const b = beatsNow();
const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection);
let label = '';
let t = moving() ? 'ok' : 'muted';
if (sect && sect.end != null && b != null) {
const leftBeats = Math.max(0, sect.end - b);
const bpb = beatsPerBar();
const beatLen = 4 / (state.timeSig?.[1] || 4);
if (looping) { label = 'Looping'; t = 'warn'; }
else if (leftBeats <= bpb + 1e-6) {
const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6));
label = `${n} beat${n === 1 ? '' : 's'}`;
if (moving()) t = 'warn';
} else {
const n = Math.ceil(leftBeats / bpb - 1e-6);
label = `${n} bars`;
}
const f = fraction(sect.start, sect.end, b);
$('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
text($('.as-count'), label);
shell.dataset.tone = t;
},
};
/* ── NEXT SONG ─────────────────────────────────────────────────── */
const nextSongView = {
render() {
const n = state.nextSong;
text($('.as-big'), n ? n.name : 'End of set');
text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : '');
show($('.as-next-dur'), on('showDuration') && n?.duration != null);
const q = state.queued;
const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : '';
text($('.as-queued'), qText);
show($('.as-queued'), on('showQueued') && Boolean(q));
shell.dataset.tone = q ? 'warn' : n ? '' : 'muted';
},
frame() {},
};
/* ── STATUS ────────────────────────────────────────────────────── */
const statusView = {
render() {
const s = state;
const rows = [
['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'],
['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'],
];
if (s.loading) rows.push(['Project', 'Loading…', 'warn']);
if (s.recording) rows.push(['Recording', 'On', 'alert']);
if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']);
if (on('showAudio')) {
if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']);
for (const a of s.audio) {
const map = {
armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'],
};
const [label, t] = map[a.status] || [a.status || 'Connected', ''];
rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']);
}
}
$('.as-rows').innerHTML = rows.map(([k, v, t]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).join('');
const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok';
shell.dataset.tone = worst;
},
frame() {},
};
const VIEW_IMPL = {
now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView,
'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView,
};
const impl = VIEW_IMPL[view];
/* ── drawing loop ──────────────────────────────────────────────── */
function render() {
if (!impl || !state) return;
const offline = !state.reachable && view !== 'status';
shell.classList.toggle('is-offline', offline);
show(offlineEl, offline);
if (offline) {
text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline');
}
impl.render();
loop();
}
function loop() {
if (raf || !moving()) return;
const step = () => {
raf = 0;
if (!state || !moving()) return;
impl.frame();
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin');
if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer');
if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected');
return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet');
}
function applyState(next) {
if (!next || typeof next !== 'object') return;
takePos(next.pos);
state = next;
reportStatus();
render();
}
/* The page's one stream is shared, so a tile mounted onto it (a gear
change remounts the tile) joins mid-flow and hears nothing until
AbleSet next changes — which, stopped, may be never. Ask for the state. */
let stopped = false;
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!stopped) applyState(body.state);
} catch { /* the stream's error handler reports the outage */ }
}
function takePos(p) {
if (!p) return;
pos = p;
posAt = performance.now();
}
return {
start() {
if (!impl) return;
moduleApi.setStatus('connecting', 'Connecting…');
if (view === 'sections') {
wheelButton = moduleApi.header.addButton({
icon: ICON_WHEEL,
title: 'Wheel view',
onClick: () => sectionsView.toggleStyle(),
});
sectionsView.syncButton();
}
stream = moduleApi.sse('/stream', {
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
},
events: {
state(e) {
let next;
try { next = JSON.parse(e.data); } catch { return; }
applyState(next);
},
pos(e) {
try { takePos(JSON.parse(e.data)); } catch { return; }
if (state && !moving()) impl.frame();
loop();
},
},
});
backfill();
resizeObserver = new ResizeObserver(() => {
fitAll();
if (view === 'sections' && state) sectionsView.place();
});
resizeObserver.observe(shell);
// Stopped tiles still have a clock to keep honest (Set Time's end time).
tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000);
},
stop() {
stopped = true;
if (stream) stream.close();
stream = null;
cancelAnimationFrame(raf);
raf = 0;
clearInterval(tickTimer);
if (resizeObserver) resizeObserver.disconnect();
root.innerHTML = '';
},
};
}
+36
View File
@@ -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."
}
}
}
+101
View File
@@ -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 };
+719
View File
@@ -0,0 +1,719 @@
'use strict';
/**
* AbleSet — server part.
*
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
* that changes to our socket, `/getValues` fills in everything that is
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
* network dropped) we keep asking again every few seconds until it answers,
* because a restarted AbleSet has forgotten its subscribers.
*
* The raw values become one view-model, streamed to every tile:
*
* GET /state → { state } (instant paint)
* GET /stream → SSE: `state` on any change, `pos` while playing
* GET /timers(/stream) → song and set countdowns for the Timers module
*
* `pos` carries only what moves every frame (playhead, time left); the
* tiles extrapolate between packets from the tempo, so 5 packets a second
* draws a smooth playhead.
*/
const dgram = require('dgram');
const dns = require('dns');
const { encodeMessage, decodePacket } = require('./osc');
const HEARTBEAT_MS = 15000; // SSE keepalive comment
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
const POS_MS = 200; // playhead packets to tiles while playing
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
/** Things AbleSet can send that no tile draws — kept off the wire. */
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
let current = null;
function emptyState() {
return {
reachable: false,
configured: false,
lastError: '',
live: false,
loading: false,
playing: false,
paused: false,
recording: false,
countingIn: false,
tempo: null,
timeSig: null,
setlist: { name: '', songs: [] },
song: null,
section: null,
nextSection: null,
sections: [],
nextSong: null,
queued: null,
loop: { enabled: false, start: null, end: null, inSection: false },
audio: [],
version: '',
pos: emptyPos(),
};
}
function emptyPos() {
return { beats: null, remainingSong: null, remainingSet: null };
}
module.exports = {
init({ config, log }) {
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 39051;
const configured = Boolean(host);
const values = new Map(); // OSC address → args, as AbleSet last said
const streams = new Set(); // tile SSE clients
const timerStreams = new Set(); // Timers-module SSE clients
let lastHeard = 0;
let lastAsked = 0;
let resolved = '';
let lastError = '';
let sock = null;
let stopped = false;
let coalesce = null;
let lastFrame = '';
let lastTimersFrame = '';
// Playhead and countdowns, each as "value at a moment" so they can be
// extrapolated to any other moment. AbleSet's time-left values are whole
// seconds; restamping on every one of them makes a countdown stutter, so
// a model is only moved when AbleSet disagrees with it by over a second.
const beatsModel = { value: null, at: 0 };
const songModel = { value: null, at: 0 };
const setModel = { value: null, at: 0 };
const num = (a, i = 0) => {
const v = values.get(a);
const n = v ? Number(v[i]) : NaN;
return v && v[i] !== null && Number.isFinite(n) ? n : null;
};
const str = (a, i = 0) => {
const v = values.get(a);
return v && v[i] != null ? String(v[i]) : '';
};
const arr = (a) => values.get(a) || [];
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
const isPlaying = () => flag('/global/isPlaying');
const tempo = () => num('/global/tempo');
function extrapolate(model, sign) {
if (model.value == null) return null;
if (!isPlaying()) return model.value;
return model.value + sign * (Date.now() - model.at) / 1000;
}
function beatsNow() {
if (beatsModel.value == null) return null;
const t = tempo();
if (!isPlaying() || !t) return beatsModel.value;
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
}
function nudge(model, reported) {
if (reported == null) { model.value = null; return; }
const predicted = extrapolate(model, -1);
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
model.value = reported;
model.at = Date.now();
} else {
// Keep the running model; re-base it so the stamp stays recent.
model.value = predicted;
model.at = Date.now();
}
}
function colorOf(a) {
const v = values.get(a);
if (!v) return null;
const name = v[0] == null ? '' : String(v[0]);
const hex = v[1] == null ? '' : String(v[1]);
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
}
function part(prefix) {
const name = str(`${prefix}Name`);
if (!name && num(`${prefix}Index`) == null) return null;
const index = num(`${prefix}Index`);
if (index != null && index < 0) return null;
return {
name,
index,
start: num(`${prefix}Start`),
end: num(`${prefix}End`),
duration: num(`${prefix}Duration`),
color: colorOf(`${prefix}Color`),
};
}
function audioInterfaces() {
const out = [];
const names = arr('/audioInterfaces/names');
const statuses = arr('/audioInterfaces/status');
names.forEach((n, i) => {
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
});
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
if (!names.length && flag('/playaudio12/isConnected')) {
const scene = num('/playaudio12/scene');
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
}
return out;
}
function buildState() {
const songs = arr('/setlist/songs');
const durations = arr('/setlist/songDurations');
const colors = arr('/setlist/songColors');
const sectionNames = arr('/setlist/sections');
const sectionColors = arr('/setlist/sectionColors');
const song = part('/setlist/activeSong');
const qIdx = values.get('/setlist/queuedIndex') || [];
const qName = values.get('/setlist/queuedName') || [];
const queuedType = str('/setlist/queuedType');
const ts = values.get('/global/timeSignature');
return {
reachable: isReachable(),
configured,
lastError,
live: flag('/global/isConnected'),
loading: flag('/global/isLoadingFile'),
playing: isPlaying(),
paused: flag('/global/isPaused'),
recording: flag('/global/isRecording'),
countingIn: flag('/setlist/isCountingIn'),
tempo: tempo(),
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
setlist: {
name: str('/setlist/name'),
songs: songs.map((name, i) => ({
name: String(name ?? ''),
duration: durations[i] == null ? null : Number(durations[i]),
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
})),
},
song,
section: part('/setlist/activeSection'),
nextSection: part('/setlist/nextSection'),
sections: sectionNames.map((name, i) => ({
name: String(name ?? ''),
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
})),
sectionLoop: {
mode: str('/setlist/activeSectionLoopMode'),
count: num('/setlist/activeSectionLoopCount'),
},
nextSong: part('/setlist/nextSong'),
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
: null,
loop: {
enabled: flag('/setlist/loopEnabled'),
start: num('/setlist/loopStart'),
end: num('/setlist/loopEnd'),
inSection: flag('/setlist/isInActiveSectionLoop'),
},
audio: audioInterfaces(),
version: str('/version/app'),
pos: buildPos(),
};
}
/**
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
* connected; without them the song's length and the playhead still say
* how far there is to go, and the set is the rest of the setlist on top.
*/
function songLeftNow() {
const reported = extrapolate(songModel, -1);
if (reported != null) return reported;
const dur = num('/setlist/activeSongDuration');
const start = num('/setlist/activeSongStart');
const end = num('/setlist/activeSongEnd');
const b = beatsNow();
if (dur == null || start == null || end == null || end <= start || b == null) return null;
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
}
function setLeftNow() {
const reported = extrapolate(setModel, -1);
if (reported != null) return reported;
const song = songLeftNow();
const idx = num('/setlist/activeSongIndex');
if (song == null || idx == null) return null;
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
}
function buildPos() {
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
return {
beats: round(beatsNow(), 1000),
remainingSong: round(songLeftNow(), 100),
remainingSet: round(setLeftNow(), 100),
};
}
function isReachable() {
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
}
/* ── fan-out ─────────────────────────────────────────────────── */
function send(set, payload) {
for (const res of set) {
try { res.write(payload); } catch { set.delete(res); }
}
}
function broadcastState() {
coalesce = null;
const state = buildState();
// Change detection ignores the moving parts, which `pos` carries.
const { pos, ...still } = state;
const frame = JSON.stringify(still);
if (frame !== lastFrame) {
lastFrame = frame;
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
broadcastTimers();
}
function scheduleState() {
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
}
function broadcastTimers() {
if (!timerStreams.size) return;
const snap = timersSnapshot();
const { updatedAt, ...rest } = snap;
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
if (key === lastTimersFrame) return;
lastTimersFrame = key;
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot() {
const now = Date.now();
const playing = isPlaying();
const reachable = isReachable();
const song = part('/setlist/activeSong');
const remSong = songLeftNow();
const remSet = setLeftNow();
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
const songs = arr('/setlist/songs');
const idx = num('/setlist/activeSongIndex');
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
return {
source: 'ableset',
label: 'AbleSet',
updatedAt: now,
timers: [
{
id: 'song',
label: 'Song',
kind: 'countdown',
state,
startedAt: playing && songElapsed != null ? now - songElapsed : null,
elapsedMs: songElapsed,
remainingMs: ms(remSong),
targetMs: ms(song?.duration),
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
detail: str('/setlist/activeSectionName') || null,
},
{
id: 'set',
label: 'Set',
kind: 'countdown',
state,
startedAt: null,
elapsedMs: null,
remainingMs: ms(remSet),
targetMs: null,
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
},
],
};
}
/* ── OSC in ──────────────────────────────────────────────────── */
const FAST = new Set([
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
]);
function onMessage(m) {
const { address, args } = m;
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
const v = Number(args[0]);
if (Number.isFinite(v)) {
// A whole-beat report while fine ones flow would drag the playhead back.
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
beatsModel.value = v;
beatsModel.at = Date.now();
}
} else if (address === '/setlist/remainingTimeInSong') {
nudge(songModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/setlist/remainingTimeInSet') {
nudge(setModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/global/isPlaying') {
// Freeze the models at the moment the transport changed.
const b = beatsNow();
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
for (const mdl of [songModel, setModel]) {
const v = extrapolate(mdl, -1);
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
}
}
values.set(address, args);
if (!FAST.has(address)) scheduleState();
}
/* ── connection ──────────────────────────────────────────────── */
function ask() {
if (!sock || stopped) return;
lastAsked = Date.now();
dns.lookup(host, { family: 4 }, (err, addr) => {
if (stopped || !sock) return;
if (err) {
lastError = `can't find ${host} (${err.code || err.message})`;
scheduleState();
return;
}
resolved = addr;
const myPort = sock.address().port;
const out = (address, args) => {
try {
sock.send(encodeMessage(address, args), port, addr, (e) => {
if (e) lastError = e.message;
});
} catch (e) {
lastError = e.message;
}
};
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
out('/getValues', ['auto', myPort]);
});
}
const watchdog = setInterval(() => {
if (!configured || stopped) return;
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
if (!isReachable()) {
if (wasReachable) {
log(`no heartbeat from AbleSet at ${host} — asking again`);
lastError = 'AbleSet stopped answering';
scheduleState();
}
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
}
}, 1000);
watchdog.unref?.();
// While playing, the playhead goes out five times a second. While
// stopped it only goes out when it moved — a jump, a scrub.
let lastPos = '';
const posTimer = setInterval(() => {
if (!streams.size || !isReachable()) return;
const frame = JSON.stringify(buildPos());
if (!isPlaying() && frame === lastPos) return;
lastPos = frame;
send(streams, `event: pos\ndata: ${frame}\n\n`);
}, POS_MS);
posTimer.unref?.();
const keepalive = setInterval(() => {
send(streams, ': ping\n\n');
send(timerStreams, ': ping\n\n');
// The Timers module derives running values itself; a fresh anchor now
// and then keeps a long song honest.
if (timerStreams.size) {
lastTimersFrame = '';
broadcastTimers();
}
}, HEARTBEAT_MS);
keepalive.unref?.();
if (configured) {
sock = dgram.createSocket('udp4');
sock.on('message', (buf, rinfo) => {
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
let msgs;
try {
msgs = decodePacket(buf);
} catch (e) {
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
return;
}
const wasReachable = isReachable();
lastHeard = Date.now();
if (!wasReachable) {
lastError = '';
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
scheduleState();
}
for (const m of msgs) onMessage(m);
});
sock.on('error', (e) => {
lastError = e.message;
log(`OSC socket error: ${e.message}`);
});
sock.bind(0, () => {
log(`following AbleSet at ${host}:${port}`);
ask();
});
} else {
log('no AbleSet host configured — set one in /admin');
}
current = {
streams,
timerStreams,
getState: buildState,
getTimers: timersSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(watchdog);
clearInterval(posTimer);
clearInterval(keepalive);
clearTimeout(coalesce);
if (sock) {
// Be polite: tell AbleSet to stop sending to a port nobody reads.
const s = sock;
sock = null;
try {
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
else s.close();
} catch {
try { s.close(); } catch { /* closed */ }
}
}
for (const set of [streams, timerStreams]) {
for (const res of set) { try { res.end(); } catch { /* gone */ } }
set.clear();
}
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
if (isReachable()) {
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
const set = str('/setlist/name');
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
}
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
},
};
},
/**
* One picker entry per tile kind. Each carries only the gear settings its
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
* Now Playing card's longer list folds into a Show group.
*/
tiles() {
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
const clock = {
type: 'select',
label: 'Song time',
default: 'remaining',
options: [
{ value: 'remaining', label: 'Time left' },
{ value: 'elapsed', label: 'Elapsed' },
{ value: 'both', label: 'Both' },
],
};
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
return [
{
id: 'now',
name: 'Now Playing',
description: 'The song, its tempo, time left, section, progress and what comes next',
defaultSize: { w: 8, h: 4 },
minSize: { w: 3, h: 2 },
instanceSchema: {
clock,
songColors: sw('Song colours', { default: false }),
showTempo: sw('Tempo', { group: 'Show' }),
showMeter: sw('Time signature', { group: 'Show' }),
showDuration: sw('Duration', { group: 'Show' }),
showClock: sw('Song time', { group: 'Show' }),
showSection: sw('Section', { group: 'Show' }),
showProgress: sw('Progress bar', { group: 'Show' }),
showNext: sw('Next song', { group: 'Show' }),
showLeft: sw('Songs left', { group: 'Show' }),
warnAt,
alertAt,
},
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
},
{
id: 'sections',
name: 'Song Sections',
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
defaultSize: { w: 8, h: 2 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showProgress: sw('Section progress'),
showSong: sw('Song name', { default: false }),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'setlist',
name: 'Setlist',
description: 'Every song in the set, played, playing and to come',
defaultSize: { w: 3, h: 6 },
minSize: { w: 2, h: 2 },
instanceSchema: {
showDurations: sw('Durations'),
showPlayed: sw('Played songs'),
showTotal: sw('Time left'),
songColors: sw('Song colours', { default: false }),
},
},
{
id: 'transport',
name: 'Transport',
description: 'Playing, stopped, counting in or looping, in colour',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showPosition: sw('Bar and beat'),
showLoop: sw('Loop'),
},
},
{
id: 'tempo',
name: 'Tempo',
description: 'BPM and time signature, with a light on every beat',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showMeter: sw('Time signature'),
showBeats: sw('Beat lights'),
},
},
{
id: 'song-time',
name: 'Song Time',
description: 'Time left in the song, turning amber then red near the end',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
clock,
showSong: sw('Song name'),
showProgress: sw('Progress bar'),
warnAt,
alertAt,
},
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
},
{
id: 'set-time',
name: 'Set Time',
description: 'Time left in the whole set and how many songs remain',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showLeft: sw('Songs left'),
showEnd: sw('End time'),
},
},
{
id: 'section',
name: 'Section',
description: 'The section playing now, what is next, and the count to the change',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showNext: sw('Next section'),
showCount: sw('Countdown'),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'next-song',
name: 'Next Song',
description: 'The song after this one, and anything queued',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showDuration: sw('Duration'),
showQueued: sw('Queued'),
},
},
{
id: 'status',
name: 'AbleSet Status',
description: 'AbleSet, Ableton Live and audio interface health at a glance',
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showAudio: sw('Audio interfaces'),
showSetlist: sw('Setlist name'),
},
},
];
},
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
const openSse = (req, res, set, first) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(first);
if (!set) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
};
return {
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
'GET /stream': (req, res) => {
const state = current ? current.getState() : emptyState();
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
'GET /timers/stream': (req, res) => {
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
};
},
};
+416
View File
@@ -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); }
+159 -2
View File
@@ -1,5 +1,9 @@
/* PCO Plan — client part.
The module presents three tiles and this is the entry for all of them: the
Teams tile (picker entry "teams") is built by teams-client.js and the Song
Key tile ("key") by key-client.js; everything below draws the Plan tile.
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu →
@@ -8,6 +12,10 @@
(saved with the tile, 14 px until someone presses one); /admin only holds
the connection settings. */
import createTeams from "./teams-client.js";
import createKey from "./key-client.js";
import { prettyKey } from "./key-notation.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
@@ -15,6 +23,13 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
// Each picker entry carries its variant and its own settings.
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
return createPlan({ root, moduleApi });
}
function createPlan({ root, moduleApi }) {
let state = null;
let skew = 0; // server clock − local clock, ms
let feedOffline = false;
@@ -22,6 +37,7 @@ export default function create({ root, moduleApi }) {
let ticker = null;
let lastCurrentId = null;
let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything
let userScrolledAt = 0;
root.innerHTML = `
@@ -354,7 +370,8 @@ export default function create({ root, moduleApi }) {
if (on('showKey') && item.keyName) {
const key = document.createElement('span');
key.className = 'pp-key';
key.textContent = item.keyName;
// B♭, F♯m — the same notation the Song Key tile uses.
key.textContent = prettyKey(item.keyName);
titleLine.appendChild(key);
}
if (on('showNowIndicator')) {
@@ -481,6 +498,133 @@ export default function create({ root, moduleApi }) {
renderLiveMarks();
}
/* ── AbleSet: the song playing under the current item ──────────────
Everything here draws only when the server says there is an AbleSet
module, it is configured and reachable, and the song it is playing is
the plan item on screen. Any of those missing and the tile looks
exactly as it did before AbleSet existed. */
/** The playhead now, in beats, carried forward from the last packet. */
function absBeatsNow() {
if (!absView || absView.beats == null) return null;
if (!absView.playing || !absView.tempo) return absView.beats;
const since = Math.max(0, serverNow() - absView.at) / 1000;
return absView.beats + since * (absView.tempo / 60);
}
function absFraction(part) {
const beats = absBeatsNow();
if (beats == null || !part || part.start == null || part.end == null) return null;
const span = part.end - part.start;
if (!(span > 0)) return null;
return Math.max(0, Math.min(1, (beats - part.start) / span));
}
/** The row AbleSet's song belongs to — only ever the current one. */
function absRow() {
const a = absView;
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
if (a.itemId !== lastCurrentId) return null;
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
}
/** Keep the playing section visible in the strip — this strip, nothing else. */
function absScrollSections(strip) {
const active = strip.querySelector('.pp-abs-sec.is-now');
if (!active) return;
const box = strip.getBoundingClientRect();
const rect = active.getBoundingClientRect();
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
if (Math.abs(delta) < 1) return;
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
}
function renderAbleSet() {
const row = absRow();
// Anything left on a row that is no longer the one goes.
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
if (!row || !row.contains(stale)) stale.remove();
}
if (!row) return;
if (on('absProgress')) {
const fraction = absFraction(absView.song);
if (fraction == null) {
row.querySelector('.pp-fill')?.remove();
} else {
let fill = row.querySelector('.pp-fill');
if (!fill) {
fill = document.createElement('div');
fill.className = 'pp-fill';
row.prepend(fill); // behind the row's content, which the stylesheet lifts
}
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
}
} else {
row.querySelector('.pp-fill')?.remove();
}
if (on('absSections') && absView.sections.length) {
const main = row.querySelector('.pp-col-main');
let strip = row.querySelector('.pp-abs-sections');
if (!strip && main) {
strip = document.createElement('div');
strip.className = 'pp-abs-sections';
main.appendChild(strip);
}
if (strip) {
// The chips are rebuilt only when the song's sections change. Moving
// on to the next section just moves the classes: rebuilding would
// throw the strip's scroll away thirteen times a song.
const key = absView.sections.map((s) => s.name).join('\u0000');
if (strip.dataset.key !== key) {
strip.dataset.key = key;
strip.dataset.now = '';
strip.innerHTML = '';
for (const section of absView.sections) {
const chip = document.createElement('span');
chip.className = 'pp-abs-sec';
chip.textContent = section.name;
strip.appendChild(chip);
}
}
const now = absView.section?.index ?? -1;
if (strip.dataset.now !== String(now)) {
strip.dataset.now = String(now);
[...strip.children].forEach((chip, i) => {
chip.classList.toggle('is-now', i === now);
chip.classList.toggle('is-done', i < now);
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
});
requestAnimationFrame(() => absScrollSections(strip));
}
const active = strip.children[now];
if (active) {
const fraction = absFraction(absView.section);
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
}
}
} else {
row.querySelector('.pp-abs-sections')?.remove();
}
}
/**
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
* scroll every ancestor that can scroll with it, which on a dashboard taller
* than the window drags the whole board; moving `listEl.scrollTop` moves
* this tile and nothing else. Measured from rectangles rather than
* `offsetTop` because the rows' offset parent is the tile, not the list.
*/
function keepInView(row) {
const list = listEl.getBoundingClientRect();
const rect = row.getBoundingClientRect();
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
if (Math.abs(delta) < 1) return;
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
}
/** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() {
const live = state?.live || {};
@@ -519,11 +663,12 @@ export default function create({ root, moduleApi }) {
}
if (!h.endedAt) elapsedEl.classList.add('is-running');
}
renderAbleSet();
if (currentId !== lastCurrentId) {
lastCurrentId = currentId;
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (row) keepInView(row);
}
}
}
@@ -598,6 +743,7 @@ export default function create({ root, moduleApi }) {
}
function tick() {
renderAbleSet();
if (!state?.plan) return;
renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
@@ -609,6 +755,7 @@ export default function create({ root, moduleApi }) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
if (payload.ableset) absView = payload.ableset;
feedOffline = false;
render();
}
@@ -629,6 +776,16 @@ export default function create({ root, moduleApi }) {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
// Its own event, several a second while a song plays: redraw that one
// row rather than the whole list.
ableset(ev) {
try {
absView = JSON.parse(ev.data);
} catch {
return;
}
if (state) renderAbleSet();
},
},
});
}
+181
View File
@@ -0,0 +1,181 @@
/* PCO Plan — the Song Key tile.
One thing, big: the key of the plan item on screen. It reads the same
streamed state the Plan tile does, so it needs no connection of its own;
which item is current comes from the ProPresenter module, as ever. */
import { parseKey, prettyKey } from "./key-notation.js";
export default function createKey({ root, moduleApi }) {
let state = null;
let stream = null;
let feedOffline = false;
root.innerHTML = `
<div class="pk-wrap">
<div class="pk-key"></div>
<div class="pk-name"></div>
<div class="pk-next"></div>
</div>`;
const wrap = root.querySelector('.pk-wrap');
const keyEl = root.querySelector('.pk-key');
const nameEl = root.querySelector('.pk-name');
const nextEl = root.querySelector('.pk-next');
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── the item on screen, and the one after it ───────────────────── */
function items() {
return Array.isArray(state?.items) ? state.items : [];
}
function currentItem() {
const id = state?.live?.following ? state.live.currentItemId : '';
return id ? items().find((it) => it.id === id) || null : null;
}
/** The next item that actually has a key — a talk between two songs is skipped. */
function nextKeyed(after) {
const list = items();
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
if (from <= 0) return null;
for (let i = from; i < list.length; i += 1) {
if (list[i].keyName) return list[i];
}
return null;
}
function titleOf(item) {
return item ? (item.song?.title || item.title || '') : '';
}
/* ── render ─────────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
const item = currentItem();
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
// The key on screen stays — the band still needs it — but a tile must
// never look confidently live while the feed that chose it is gone.
if (!state.live.available) {
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
}
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
}
/**
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
* better with the accidental raised and the minor kept small.
*/
function drawKey(text) {
keyEl.innerHTML = '';
const parts = parseKey(text);
if (!parts) {
keyEl.textContent = text;
return;
}
keyEl.appendChild(document.createTextNode(parts.note));
if (parts.accidental) {
const acc = document.createElement('span');
acc.className = 'pk-accidental';
acc.textContent = parts.accidental;
keyEl.appendChild(acc);
}
if (parts.quality) {
const tail = document.createElement('span');
tail.className = 'pk-quality';
tail.textContent = parts.quality;
keyEl.appendChild(tail);
}
}
function render() {
wrap.classList.toggle('is-offline', feedOffline);
const live = state?.live;
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
const color = String(cfg().keyColor || '').trim();
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
renderStatus();
const item = currentItem();
const key = item?.keyName || '';
wrap.classList.toggle('has-key', Boolean(key));
if (!state || !state.configured || !state.serviceTypeSet) {
drawKey('—');
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
nextEl.textContent = '';
return;
}
if (!item) {
drawKey('—');
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
nextEl.textContent = '';
return;
}
drawKey(key || '—');
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
nameEl.hidden = !nameEl.textContent;
const next = on('showNextKey') ? nextKeyed(item) : null;
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
nextEl.hidden = !nextEl.textContent;
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { feedOffline = false; render(); },
error() { feedOffline = true; render(); },
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
render();
},
stop() {
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+25
View File
@@ -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}`;
}
+272 -3
View File
@@ -1,9 +1,9 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.6.0",
"version": "1.9.0",
"proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services",
"description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [
"timers"
],
@@ -382,6 +382,275 @@
"label": "Match line",
"default": true,
"group": "Show"
},
"absProgress": {
"type": "boolean",
"label": "Song progress",
"default": true,
"group": "Show"
},
"absSections": {
"type": "boolean",
"label": "Song sections",
"default": true,
"group": "Show"
}
}
},
"tiles": [
{
"id": "plan",
"name": "Plan",
"description": "The order of service, item by item",
"defaultSize": {
"w": 5,
"h": 6
},
"minSize": {
"w": 3,
"h": 3
}
},
{
"id": "teams",
"name": "Teams",
"description": "Who is scheduled, by team and position",
"defaultSize": {
"w": 4,
"h": 6
},
"minSize": {
"w": 2,
"h": 2
},
"instanceSchema": {
"columns": {
"type": "select",
"label": "Columns",
"default": "auto",
"options": [
{
"value": "auto",
"label": "Fit the tile"
},
{
"value": "1",
"label": "One"
},
{
"value": "2",
"label": "Two"
},
{
"value": "3",
"label": "Three"
}
]
},
"teams": {
"type": "multiselect",
"label": "Teams",
"columns": 2,
"allByDefault": true,
"optionsRoute": "/teams"
},
"headerTemplate": {
"type": "template",
"label": "Top bar text",
"default": "{serviceType} • {date} | {people} on {teams}",
"group": "Top bar",
"variables": [
{
"value": "{series}",
"label": "Series"
},
{
"value": "{part}",
"label": "Part / plan title"
},
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{people}",
"label": "People count"
},
{
"value": "{teams}",
"label": "Team count"
},
{
"value": "{confirmed}",
"label": "Confirmed count"
}
],
"help": "Use • between items and | to start a second line."
},
"teamColor": {
"type": "color",
"label": "Team headers",
"default": "#2ee59a",
"group": "Colors"
},
"tintTeams": {
"type": "boolean",
"label": "Tint teams",
"default": false,
"group": "Colors"
},
"tintRows": {
"type": "boolean",
"label": "Tint rows too",
"default": false,
"group": "Colors",
"showWhen": "tintTeams"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"confirmedColor": {
"type": "color",
"label": "Confirmed",
"default": "#2ee59a",
"group": "Colors"
},
"unconfirmedColor": {
"type": "color",
"label": "Unconfirmed",
"default": "#f0b429",
"group": "Colors"
},
"declinedColor": {
"type": "color",
"label": "Declined",
"default": "#f4433c",
"group": "Colors"
},
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showTeamHeaders": {
"type": "boolean",
"label": "Team headers",
"default": true,
"group": "Show"
},
"showTeamCounts": {
"type": "boolean",
"label": "Team counts",
"default": true,
"group": "Show"
},
"showAvatars": {
"type": "boolean",
"label": "Avatars",
"default": true,
"group": "Show"
},
"showPosition": {
"type": "boolean",
"label": "Positions",
"default": true,
"group": "Show"
},
"groupByPosition": {
"type": "boolean",
"label": "Group by position",
"default": false,
"group": "Show"
},
"showStatus": {
"type": "boolean",
"label": "Status",
"default": true,
"group": "Show"
},
"showNotes": {
"type": "boolean",
"label": "Person notes",
"default": false,
"group": "Show"
},
"showDeclined": {
"type": "boolean",
"label": "Declined people",
"default": true,
"group": "Show"
},
"showUnconfirmed": {
"type": "boolean",
"label": "Unconfirmed people",
"default": true,
"group": "Show"
},
"showEmptyTeams": {
"type": "boolean",
"label": "Teams with nobody",
"default": false,
"group": "Show"
}
},
"instanceGroups": {
"Top bar": {
"collapsed": false
},
"Colors": {
"collapsed": false,
"columns": 2
},
"Show": {
"collapsed": true,
"columns": 2
}
}
},
{
"id": "key",
"name": "Song Key",
"description": "The key of the item on screen, big",
"defaultSize": {
"w": 2,
"h": 2
},
"minSize": {
"w": 1,
"h": 1
},
"instanceSchema": {
"keyColor": {
"type": "color",
"label": "Key",
"default": "#4ea1ff"
},
"showSongName": {
"type": "boolean",
"label": "Song name",
"default": true
},
"showNextKey": {
"type": "boolean",
"label": "Next key",
"default": true
}
}
}
]
}
+521 -2
View File
@@ -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
@@ -17,6 +21,8 @@
* (documents ProPresenter has shown or lists — see below)
* GET /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name
* GET /teams → { options } for a Teams tile's "Teams" switches
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
*
@@ -29,6 +35,14 @@
* GET …/plans/{id}/plan_times (service start times)
* GET …/plans/{id}/items?include=song,arrangement,item_notes
* GET /service_types/{st}/item_note_categories (note category names)
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
*
* Following AbleSet: the same loopback trick, entirely optional. When this
* ProdDash has an AbleSet module that is configured and answering, its song,
* sections and playhead are passed to the tiles, and the song is matched to a
* plan item by name so a tile can fill that item as the song plays. With no
* such module — not installed, disabled, no host — tiles are told
* `available: false` and draw none of it.
*
* Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over
@@ -56,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,
@@ -82,9 +96,28 @@ const HEARTBEAT_MS = 15000;
/** The Now/Next module whose feed we follow. */
const NOW_NEXT_ID = 'propresenter-now-next';
const NOW_NEXT_RETRY_MS = 3000;
/** The AbleSet module, followed the same way — entirely optional (see below). */
const ABLESET_ID = 'ableset';
const ABLESET_RETRY_MS = 3000;
const ABLESET_MISSING_RETRY_MS = 15000;
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
const ABLESET_POS_MS = 200;
const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200;
/**
* Team assignments change far more slowly than the plan itself (someone
* confirms, a position is filled), so they ride a slow lane: re-read when the
* plan changes, and otherwise no more often than this.
*/
const TEAMS_MIN_MS = 60 * 1000;
/** How long the service type's own team list is believed. */
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
const AVATAR_CACHE_MAX = 300;
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
/** How long a service type's item note categories are believed before Planning Center is asked again. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
/**
@@ -112,6 +145,8 @@ function clearedState() {
serviceType: { id: '', name: '' },
plan: null,
items: [],
teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did
live: clearedLive(),
service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets)
@@ -135,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 {
@@ -372,6 +429,168 @@ function shapePlan(planRec, serviceTypeId, planTimes) {
};
}
/* ── team assignments (the Teams tile) ──────────────────────────────── */
/**
* Planning Center's scheduling status letters. The API answers "C", "U" or
* "D"; some accounts and older records spell the word out, so match on the
* first letter and treat anything unknown as not yet confirmed — the state a
* booth should chase.
*/
function teamStatusOf(raw) {
const s = String(raw || '').trim().toUpperCase();
if (s.startsWith('C')) return 'confirmed';
if (s.startsWith('D')) return 'declined';
return 'unconfirmed';
}
/**
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
* plan is read with `include=person` the Person record carries the fuller set
* of names Planning Center has used for the same thing over the years. Take
* the first that looks like a URL — a missing photo is normal and the tile
* draws initials instead.
*/
function teamPhotoOf(attrs, person) {
const candidates = [
attrs?.photo_thumbnail,
attrs?.photo_url,
person?.attributes?.avatar,
person?.attributes?.photo_thumbnail_url,
person?.attributes?.photo_url,
person?.attributes?.thumbnail_url,
];
for (const c of candidates) {
const url = String(c || '').trim();
if (/^https?:\/\//i.test(url)) return url;
}
return '';
}
/**
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
* each with its people. Teams keep Planning Center's own order (a Team's
* `sequence`, then name); people sort by position, then by name, so a tile
* reads the way the schedule page does.
*
* Everyone scheduled belongs to a team, but a record whose team relationship
* is missing (a deleted team, a partial response) is not dropped on the
* floor — it lands under "Unassigned" where somebody will notice it.
*
* `allTeams` is the service type's own team list. Planning Center only
* returns a team here once somebody is scheduled on it, so without that list
* a team nobody has signed up for is invisible — which is the one a booth
* most wants to see. It also carries the authoritative name and order.
*/
function shapeTeams(body, allTeams) {
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
const teams = new Map();
for (const pp of body.data || []) {
const a = pp.attributes || {};
const teamRef = pp.relationships?.team?.data;
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
if (!teams.has(teamId)) {
teams.set(teamId, {
id: teamId,
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
sequence: Number(teamRec?.attributes?.sequence) || 0,
members: [],
});
}
const personRef = pp.relationships?.person?.data;
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
teams.get(teamId).members.push({
id: String(pp.id),
personId: personRef?.id ? String(personRef.id) : '',
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
position: String(a.team_position_name || '').trim(),
status: teamStatusOf(a.status),
declineReason: String(a.decline_reason || '').trim(),
notes: String(a.notes || '').trim(),
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
});
}
// Teams nobody is scheduled on yet, and the real name/order for the rest.
for (const t of allTeams || []) {
const known = teams.get(t.id);
if (known) {
if (t.name) known.name = t.name;
known.sequence = t.sequence;
} else {
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
}
}
const out = [...teams.values()];
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
for (const team of out) {
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
team.counts = {
total: team.members.length,
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
declined: team.members.filter((m) => m.status === 'declined').length,
};
}
return out;
}
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
ever talks to Planning Center (same reasoning as every other upstream). */
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
const avatarInFlight = new Map();
function fetchAvatar(url) {
const hit = avatarCache.get(url);
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
const p = (async () => {
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
const buf = Buffer.from(await response.arrayBuffer());
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
const entry = { buf, type, at: Date.now() };
avatarCache.set(url, entry);
return entry;
})().finally(() => avatarInFlight.delete(url));
avatarInFlight.set(url, p);
return p;
}
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
function avatarUrlAllowed(url) {
try {
const u = new URL(String(url || ''));
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
} catch {
return false;
}
}
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
async function serveAvatar(res, url) {
if (!avatarUrlAllowed(url)) {
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
return void res.end('No photo');
}
try {
const img = await fetchAvatar(url);
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
res.end(img.buf);
} catch {
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('Photo unavailable');
}
}
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
function serviceLabel(win) {
if (!win || !win.startsAt) return '';
@@ -549,6 +768,98 @@ module.exports = {
}
}
/* — team assignments (the Teams tile) — */
/**
* The service type's teams, cached: they change when somebody edits the
* service type, not during a service. A failed read is not fatal — the
* plan's own teams still draw, just without the empty ones.
*/
let serviceTeams = { at: 0, list: [] };
async function readServiceTeams() {
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
try {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
const list = (body.data || []).map((t, i) => ({
id: String(t.id),
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
sequence: Number(t.attributes?.sequence) || i + 1,
}));
serviceTeams = { at: Date.now(), list };
} catch (err) {
log(`service type team list read failed: ${err?.message || err}`);
serviceTeams.at = Date.now(); // don't retry every poll
}
return serviceTeams.list;
}
/** planPersonId → photo URL, so a browser never learns the CDN address. */
let teamPhotos = new Map();
let teamsReadAt = 0;
/**
* Who is scheduled on this plan. Asked for with `include=team,person` and
* read again defensively without the includes when an account refuses
* them, exactly as the items read does: a tile that can name people
* without photos is far better than a tile that shows nothing.
*/
async function readTeams(planId) {
const target = `${planPath(planId)}/team_members`;
let body;
try {
body = await pco.getAll(target, { include: 'team,person' });
} catch (err) {
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
log(`team include rejected (${err.message}) — reading team members without includes`);
body = await pco.getAll(target);
} else {
throw err;
}
}
const teams = shapeTeams(body, await readServiceTeams());
const photos = new Map();
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
for (const pp of body.data || []) {
const personRef = pp.relationships?.person?.data;
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
if (url) photos.set(String(pp.id), url);
}
teamPhotos = photos;
teamsReadAt = Date.now();
return teams;
}
/**
* Refresh the team list when it is worth it: a new plan, or the slow lane
* coming round. A failure keeps the last good list and says so in the
* state — the plan itself is what the booth needs, and losing the roster
* must never cost them the running order.
*/
async function refreshTeams(planId, force) {
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
try {
const teams = await readTeams(planId);
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
const after = teams.reduce((n, t) => n + t.members.length, 0);
state.teams = teams;
state.teamsError = '';
if (force || before !== after) {
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
}
} catch (err) {
state.teamsError = err?.message || String(err);
log(`team read failed: ${state.teamsError}`);
}
}
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
function teamOptions() {
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
}
async function readPlan() {
if (!pco.configured || !serviceTypeId) return;
let planRec = null;
@@ -573,6 +884,8 @@ module.exports = {
if (state.plan) log('no plan found any more — service timing reset');
state.plan = null;
state.items = [];
state.teams = [];
state.teamsError = "";
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
@@ -589,6 +902,8 @@ module.exports = {
state.plan = plan;
state.items = items;
noteMessageItem(items);
// The roster rides its own slow lane and never fails the plan read.
await refreshTeams(plan.id, planChanged);
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
@@ -982,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);
@@ -993,6 +1472,10 @@ module.exports = {
try { nnRequest?.destroy(); } catch { /* gone */ }
nnRequest = null;
connectNowNext();
clearTimeout(absRetryTimer);
try { absRequest?.destroy(); } catch { /* gone */ }
absRequest = null;
connectAbleSet();
}
/**
@@ -1251,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(() => {
@@ -1283,6 +1769,7 @@ module.exports = {
timerStreams,
pco,
getState: () => state,
getAbleSet: () => abs,
learnPort,
refresh() {
clearTimeout(pollTimer);
@@ -1293,6 +1780,8 @@ module.exports = {
timersSnapshot,
documentOptions,
noteCategoryOptions,
teamOptions,
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
};
return {
@@ -1302,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 */ }
@@ -1344,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) => {
@@ -1352,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 */ }
@@ -1385,6 +1882,28 @@ module.exports = {
req.on('close', () => timerStreams.delete(res));
},
/**
* Options for a Teams tile's "Teams" switches. Like the note
* categories, always a 200 with a list: an empty one simply means there
* is nothing scheduled to choose from yet, which the gear window says
* plainly.
*/
'GET /teams': (req, res) => {
json(res, 200, current ? current.teamOptions() : { options: [] });
},
/**
* A scheduled person's photo, proxied from Planning Center and cached.
* The tile asks by plan-person id and never sees the CDN URL; an id the
* module does not know (an old tile, a person since unscheduled) is a
* 404 and the tile falls back to initials.
*/
'GET /team-avatar/*': (req, res) => {
const id = String(req.wildcard || '').replace(/^\/+/, '');
const url = current ? current.avatarUrlFor(id) : '';
serveAvatar(res, url);
},
/** Options for the admin "Start document" / "End document" selects. */
'GET /documents': (req, res) => {
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
+380 -1
View File
@@ -86,6 +86,7 @@
}
[data-module="pco-plan"] .pp-row {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.6em;
@@ -205,7 +206,9 @@
[data-module="pco-plan"] .pp-now-tag {
display: none;
padding: 0.05em 0.45em;
/* Optically centred like the section chips: uppercase has no descenders,
so the capitals sat 0.59px low in the pill. */
padding: 0.018em 0.45em 0.082em;
border-radius: 999px;
background: var(--pp-current);
color: var(--bg);
@@ -365,3 +368,379 @@
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
/* ── the Teams tile ───────────────────────────────────────────────────
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
colours come from the tile's own settings (--tm-team per section,
--tm-status per person) over the shell's palette. */
[data-module="pco-plan"] .tm-wrap {
--pp-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
font-size: var(--pp-size);
color: var(--text);
}
[data-module="pco-plan"] .tm-head {
flex: none;
padding: 0.6em 0.85em 0.5em;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
[data-module="pco-plan"] .tm-head[hidden] { display: none; }
[data-module="pco-plan"] .tm-title {
font-size: 1.1em;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub {
margin-top: 0.15em;
font-size: 0.82em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
[data-module="pco-plan"] .tm-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 0.6em;
overscroll-behavior: contain;
}
/* ── a team ── */
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
/* The header band runs edge to edge, like the Plan tile's section headers. */
[data-module="pco-plan"] .tm-team-head {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.45em 0.9em 0.4em;
margin-top: 0.7em;
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
}
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
[data-module="pco-plan"] .header-gradient .tm-team-head {
background: linear-gradient(90deg,
color-mix(in srgb, var(--tm-team) 30%, transparent),
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
transparent);
}
[data-module="pco-plan"] .tm-team-name {
flex: 1;
min-width: 0;
font-size: 0.85em;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-team-count {
flex: none;
font-size: 0.78em;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
[data-module="pco-plan"] .tm-people {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
gap: 0.15em 0.5em;
padding: 0.35em 0.6em 0.2em;
}
[data-module="pco-plan"] .tm-people.is-fixed-cols {
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
}
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
/* Grouped by position: headings span, each position's people form their own grid. */
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
[data-module="pco-plan"] .tm-position-head {
padding: 0.4em 0.9em 0;
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="pco-plan"] .tm-team-empty {
grid-column: 1 / -1;
padding: 0.2em 0.3em 0.4em;
font-size: 0.85em;
font-style: italic;
color: var(--muted);
}
/* ── one person ── */
[data-module="pco-plan"] .tm-person {
display: flex;
align-items: center;
gap: 0.55em;
min-width: 0;
padding: 0.3em 0.4em;
border-radius: 0.45em;
}
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
[data-module="pco-plan"] .tm-avatar {
position: relative;
flex: none;
width: 2.1em;
height: 2.1em;
border-radius: 999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
color: var(--text);
font-size: 0.78em;
font-weight: 800;
letter-spacing: 0.02em;
user-select: none;
}
/* The photo covers the initials; if it fails to load the script drops it. */
[data-module="pco-plan"] .tm-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--panel);
}
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
[data-module="pco-plan"] .tm-name {
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
[data-module="pco-plan"] .tm-position {
font-size: 0.8em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-note {
margin-top: 0.1em;
font-size: 0.78em;
line-height: 1.3;
color: color-mix(in srgb, var(--text) 75%, var(--muted));
overflow-wrap: anywhere;
}
[data-module="pco-plan"] .tm-status {
flex: none;
width: 0.55em;
height: 0.55em;
border-radius: 999px;
background: var(--tm-status, var(--muted));
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
}
/* ── empty / degraded ── */
[data-module="pco-plan"] .tm-empty {
padding: 12% 1.2em 1em;
text-align: center;
color: var(--muted);
}
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
[data-module="pco-plan"] .tm-empty-link {
display: inline-block;
margin-top: 0.9em;
padding: 0.35em 0.9em;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 999px;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
[data-module="pco-plan"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
/* ── AbleSet under the current item ───────────────────────────────────
Drawn only while an AbleSet module is configured, reachable, and playing
the song this item is. The fill is a layer behind the row's own content;
everything else in the row is lifted over it. */
[data-module="pco-plan"] .pp-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 0;
border-radius: 0.5em 0 0 0.5em;
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
/* Packets arrive about five times a second; the glide covers the gaps. */
transition: width 0.22s linear;
pointer-events: none;
}
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
/* The sections strip, bottom right of the current item. */
[data-module="pco-plan"] .pp-abs-sections {
display: flex;
gap: 0.3em;
/* Bottom right of the item: hug the content and sit against the right edge
until there are more sections than fit, when it fills the width and
scrolls instead. */
width: fit-content;
max-width: 100%;
margin-top: 0.35em;
margin-left: auto;
padding-bottom: 0.1em;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
overscroll-behavior: contain;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
}
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
/* Uppercase text has no descenders, so the font's descent allowance below
the baseline leaves the caps sitting high in the box — measured at 0.9px
too much headroom. The padding is a touch shallower on top and deeper
below to centre the letters themselves; in em, so it holds at any text
size. */
padding: 0.095em 0.55em 0.185em;
/* The same rounded square, tinted ground and transparent border the AbleSet
module gives its own section pills, so the two read as one family. */
border-radius: 0.35em;
border: 1px solid transparent;
background: color-mix(in srgb, var(--text) 8%, var(--panel));
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
/* The section now playing fills left to right as it goes, like the row. */
/* The section playing keeps its own ground and fills across it, so the
progress reads against the chip rather than against the row behind it. */
[data-module="pco-plan"] .pp-abs-sec.is-now {
color: var(--text);
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
background:
linear-gradient(90deg,
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
}
/* ── the Song Key tile ────────────────────────────────────────────────
One value, as large as the tile allows. Sizes come from the tile itself
(container units), like the AbleSet module's single readouts, so a small
tile and a wall-sized one both read properly. */
[data-module="pco-plan"] .pk-wrap {
--pk-color: #4ea1ff;
position: absolute;
inset: 0;
container-type: size;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2em;
padding: 4%;
text-align: center;
user-select: none;
}
[data-module="pco-plan"] .pk-key {
font-size: clamp(18px, 46cqmin, 260px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.01em;
color: var(--muted);
white-space: nowrap;
}
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
/* A key is a note, but it has to read as one word at a glance from the back
of a room: the accidental and the quality sit on the same line as the
letter, only smaller. Raising the accidental looked like two stacked
characters at tile sizes. */
[data-module="pco-plan"] .pk-accidental {
font-size: 0.68em;
margin-left: 0.01em;
}
[data-module="pco-plan"] .pk-quality {
font-size: 0.5em;
font-weight: 700;
margin-left: 0.05em;
opacity: 0.85;
}
[data-module="pco-plan"] .pk-name {
max-width: 100%;
font-size: clamp(9px, 9cqmin, 30px);
font-weight: 600;
line-height: 1.2;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
[data-module="pco-plan"] .pk-next {
max-width: 100%;
font-size: clamp(8px, 6.5cqmin, 20px);
font-weight: 600;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
/* The feed that picks the item is gone: the key stays readable, but faded,
so nobody trusts it as current without looking at the status dot. */
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
+435
View File
@@ -0,0 +1,435 @@
/* PCO Plan — the Teams tile.
Who is scheduled on the current plan, by team and position, from the same
server state the Plan tile draws (state.teams). Everything shown is this
tile's own choice (gear menu): which teams and positions, what each row
carries, and the colours. Photos come from ProdDash's own avatar route —
the browser never talks to Planning Center. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
export default function createTeams({ root, moduleApi }) {
let state = null;
let feedOffline = false;
let stream = null;
let lastKey = '';
root.innerHTML = `
<div class="tm-wrap">
<header class="tm-head">
<div class="tm-title"></div>
<div class="tm-sub"></div>
</header>
<div class="tm-body"></div>
</div>`;
const wrap = root.querySelector('.tm-wrap');
const headEl = root.querySelector('.tm-head');
const titleEl = root.querySelector('.tm-title');
const subEl = root.querySelector('.tm-sub');
const bodyEl = root.querySelector('.tm-body');
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── title-bar controls ─────────────────────────────────────────── */
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read from Planning Center',
onClick() {
refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' })
.catch(() => { /* the stream reports the outcome */ })
.finally(() => refreshBtn.classList.remove('active'));
},
});
/* ── helpers ────────────────────────────────────────────────────── */
function colorOf(value, fallback) {
const s = String(value || '').trim();
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
}
/**
* A team's colour when "Tint teams" is on. Taken from the team's position
* in the plan, not a hash of its name: neighbouring teams then always look
* different, which is the whole point of tinting them.
*/
function teamColor(index) {
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
}
function statusColor(status) {
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
return colorOf(cfg().unconfirmedColor, '#f0b429');
}
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
/** "Sarah Miles" → "SM"; one word → its first two letters. */
function initialsOf(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!words.length) return '?';
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/** A saved multiselect list, or null when the tile has never chosen (= all). */
function chosen(key) {
const v = cfg()[key];
return Array.isArray(v) ? v : null;
}
/* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() {
const teams = Array.isArray(state?.teams) ? state.teams : [];
const wantTeams = chosen('teams');
const out = [];
for (const team of teams) {
if (wantTeams && !wantTeams.includes(team.id)) continue;
const members = team.members.filter((m) => {
if (m.status === 'declined' && !on('showDeclined')) return false;
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
return true;
});
if (!members.length && !cfg().showEmptyTeams) continue;
out.push({ ...team, members });
}
return out;
}
/* ── status dot ─────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
}
/* ── top bar ────────────────────────────────────────────────────── */
/** The counts describe what this tile shows, not what the plan holds. */
function templateValues() {
const plan = state?.plan || {};
const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return {
series: plan.seriesTitle || '',
part: plan.title || '',
servicetype: state?.serviceType?.name || '',
date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: '',
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
confirmed: confirmed ? `${confirmed} confirmed` : '',
};
}
/**
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
* a line • separates segments and a segment whose placeholders all came out
* empty is dropped, so a plan with no series never leaves a stray bullet.
*/
function renderTemplate(template, values) {
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
const kept = [];
for (const seg of line.split(/\s*[•·]\s*/)) {
let sawVar = false;
let allEmpty = true;
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
sawVar = true;
const v = values[name.toLowerCase()];
if (v) allEmpty = false;
return v || '';
}).trim();
if (sawVar && allEmpty) continue;
if (text) kept.push(text);
}
return kept.join(' • ');
});
}
function renderHead() {
const show = on('showTopBar') && state?.plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
subEl.textContent = line1 ? line2 || '' : '';
subEl.hidden = !subEl.textContent;
}
/* ── the list ───────────────────────────────────────────────────── */
function emptyMessage() {
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
if (!state) return { title: 'Connecting…', hint: '' };
if (!state.configured) {
return {
title: 'Planning Center not connected',
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.serviceTypeSet) {
return {
title: 'No service type selected',
hint: 'Pick the service type to follow under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
};
}
if (!(state.teams || []).length) {
return {
title: 'Nobody scheduled yet',
hint: state.teamsError || 'This plan has no team members in Planning Center.',
};
}
if (!visibleTeams().length) {
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
}
return null;
}
function buildMember(member) {
const row = document.createElement('div');
row.className = `tm-person is-${member.status}`;
if (on('showAvatars')) {
const avatar = document.createElement('div');
avatar.className = 'tm-avatar';
avatar.textContent = initialsOf(member.name);
if (member.hasPhoto) {
const img = document.createElement('img');
img.loading = 'lazy';
img.alt = '';
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
// No photo, or the CDN is unreachable: the initials underneath stand.
img.addEventListener('error', () => img.remove());
avatar.appendChild(img);
}
row.appendChild(avatar);
}
const main = document.createElement('div');
main.className = 'tm-person-main';
const nameEl = document.createElement('div');
nameEl.className = 'tm-name';
nameEl.textContent = member.name;
main.appendChild(nameEl);
if (on('showPosition') && member.position && !cfg().groupByPosition) {
const pos = document.createElement('div');
pos.className = 'tm-position';
pos.textContent = member.position;
main.appendChild(pos);
}
if (cfg().showNotes && (member.notes || member.declineReason)) {
const note = document.createElement('div');
note.className = 'tm-note';
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
main.appendChild(note);
}
row.appendChild(main);
if (on('showStatus')) {
const status = document.createElement('div');
status.className = 'tm-status';
status.style.setProperty('--tm-status', statusColor(member.status));
status.title = STATUS_LABEL[member.status] || member.status;
row.appendChild(status);
}
return row;
}
/** One team: its header band, then its people (optionally under position sub-headings). */
function buildTeam(team, index) {
const section = document.createElement('section');
section.className = 'tm-team';
section.style.setProperty('--tm-team', teamColor(index));
if (on('showTeamHeaders')) {
const header = document.createElement('div');
header.className = 'tm-team-head';
const name = document.createElement('span');
name.className = 'tm-team-name';
name.textContent = team.name;
header.appendChild(name);
if (on('showTeamCounts')) {
const counts = document.createElement('span');
counts.className = 'tm-team-count';
const pending = team.counts.unconfirmed;
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
header.appendChild(counts);
}
section.appendChild(header);
}
const columns = String(cfg().columns || 'auto');
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
// Grouped, the container is a plain block so each position heading spans
// the width and only its own people form a grid.
const list = document.createElement('div');
list.className = grouping ? 'tm-groups' : 'tm-people';
if (!grouping && columns !== 'auto') {
list.style.setProperty('--tm-cols', columns);
list.classList.add('is-fixed-cols');
}
if (grouping) {
// Positions keep the order the members already sort in.
const groups = new Map();
for (const m of team.members) {
const key = m.position || '(no position)';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
for (const [position, members] of groups) {
const label = document.createElement('div');
label.className = 'tm-position-head';
label.textContent = position;
list.appendChild(label);
const sub = document.createElement('div');
sub.className = 'tm-people is-sub';
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
for (const m of members) sub.appendChild(buildMember(m));
list.appendChild(sub);
}
} else {
for (const m of team.members) list.appendChild(buildMember(m));
}
if (!team.members.length) {
const none = document.createElement('div');
none.className = 'tm-team-empty';
none.textContent = 'Nobody scheduled';
list.appendChild(none);
}
section.appendChild(list);
return section;
}
function renderBody() {
const empty = emptyMessage();
if (empty) {
lastKey = '';
bodyEl.innerHTML = '';
const box = document.createElement('div');
box.className = 'tm-empty';
const t = document.createElement('div');
t.className = 'tm-empty-title';
t.textContent = empty.title;
box.appendChild(t);
if (empty.hint) {
const h = document.createElement('div');
h.className = 'tm-empty-hint';
h.textContent = empty.hint;
box.appendChild(h);
}
if (empty.link) {
const a = document.createElement('a');
a.className = 'tm-empty-link';
a.href = empty.link.href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = empty.link.text;
box.appendChild(a);
}
bodyEl.appendChild(box);
return;
}
// Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams();
const key = JSON.stringify([teams, cfg()]);
if (key === lastKey) return;
lastKey = key;
bodyEl.innerHTML = '';
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
}
function render() {
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
wrap.classList.toggle('header-gradient', on('headerGradient'));
renderStatus();
renderHead();
renderBody();
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { feedOffline = false; render(); },
error() { feedOffline = true; render(); },
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
render();
},
stop() {
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+34 -5
View File
@@ -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 || '';
+139 -29
View File
@@ -10,13 +10,23 @@
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
*
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
* Error strings are two-tier on purpose: `error` is what a dashboard tile
* shows (short — "Not configured — see Admin"), `detail` is the full story
* for the admin health line and the log.
*
* The capture 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 };
+7 -1
View File
@@ -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",
+113
View File
@@ -0,0 +1,113 @@
'use strict';
/**
* Phase lock for whole-second timer reports (the ProPresenter poller).
*
* ProPresenter reports a running timer as whole seconds — trunc(value):
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
* old poller re-anchored a tile's local count at the bottom of that interval
* on every poll, so the displayed digit ran up to a second ahead and then
* got yanked back a step whenever a fresh poll happened to sample the next
* flip later than the last one did — the poll clock and ProPresenter's
* second boundaries drift through each other, so tiles flickered
* "…12:33 → 12:34 → 12:33…" about once a second.
*
* This tracker turns the quantized reports into ONE stable anchor
* { valueMs, at } — the continuous value at a fixed instant — which the
* client extrapolates from. Rules:
*
* - The only real information in a poll is a FLIP: the report stepping one
* second in the direction of travel. At the flip instant the true value
* equals the interval boundary just crossed, and the flip happened between
* the last two polls, so their midpoint dates it to within half a poll.
* - While a poll's report matches what the anchor predicts (within TOL_MS
* around the reported second's interval), the anchor HOLDS — the same
* numbers are republished, so consumers count along one steady clock
* instead of a freshly re-phased one per sample.
* - A report the anchor can't explain re-anchors: at the flip boundary when
* this poll saw a clean one-second flip, else mid-interval (first sight,
* an operator reset/seek, a resume, or clock drift past TOL_MS).
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
* whole-second bias one way.
*
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
* crosses +1 s.
*/
/** How far a report may sit outside the anchor's prediction before it means
something real. A flip is dated to the midpoint of two polls, so its own
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
runs long); the tolerance must sit ABOVE that or consecutive flips
re-anchor each other forever, and safely below a full second so a real
one-second seek still registers. */
const TOL_MS = 450;
/** The continuous-value interval implied by a whole-second report. */
function intervalFor(kind, wholeMs) {
if (kind === 'countdown') {
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
}
return { lo: wholeMs, hi: wholeMs + 1000 };
}
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
function boundaryFor(kind, wholeMs) {
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
return wholeMs;
}
/**
* One tracker per timer. Feed one observation per poll; get back what to
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
* stable across polls while the reports stay consistent with it. Not
* running (or no value): the report is returned verbatim, dated at the poll.
*/
function createTimerPhase() {
let anchor = null; // { valueMs, at }
let kind = '';
let lastWhole = null;
let lastAt = 0;
let wasRunning = false;
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
const dir = k === 'countdown' ? -1 : 1;
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
&& lastWhole !== null && wholeMs !== null
&& wholeMs - lastWhole === dir * 1000;
const prevAt = lastAt;
lastWhole = wholeMs;
lastAt = pollAt;
wasRunning = Boolean(running);
if (k !== kind) anchor = null;
kind = k;
if (!running || wholeMs === null) {
anchor = null;
return { valueMs: wholeMs, asOf: pollAt };
}
const { lo, hi } = intervalFor(k, wholeMs);
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
// tolerance check may fail to catch for a long time (its error only
// shows at a poll landing more than TOL_MS past a flip). The first
// clean flip is strictly better evidence (±½ poll), so it always wins.
if (anchor && !(anchor.provisional && flip)) {
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
}
anchor = flip
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
return { observe };
}
module.exports = { createTimerPhase, TOL_MS };
@@ -0,0 +1,205 @@
'use strict';
/**
* Self-test for phase.js — the whole-second phase lock — plus the client's
* never-tick-backwards guard, driven by a simulated ProPresenter:
*
* node modules/propresenter-timers/phase.selftest.js
*
* The simulation runs a true continuous timer, reports it the way
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
* jitter), feeds the reports through createTimerPhase(), then renders the
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
*
* - the displayed digits NEVER step against the direction of travel
* (the reported bug: countdown tiles flicking back to the previous
* second about once a second),
* - each displayed second dwells ~1 s,
* - the published anchor stays within tolerance of the true value,
* - operator resets/seeks snap within a poll or two,
* - pause → resume re-locks,
* - a ProPresenter whose clock drifts against the server's stays locked.
*/
const { createTimerPhase, TOL_MS } = require('./phase');
let checks = 0;
let failures = 0;
function ok(name, cond, detail) {
checks += 1;
if (!cond) {
failures += 1;
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
}
}
/** Deterministic pseudo-random (so a failure reproduces). */
function makeRand(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
/** ProPresenter's report: whole seconds, truncated toward zero. */
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
/**
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
* timers report a frozen value via `frozen`).
*/
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
const rand = makeRand(seed);
const phase = createTimerPhase();
const dir = kind === 'countdown' ? -1 : 1;
const polls = [];
for (let t = 0; t <= durationMs;) {
const running = runningAt(t);
const value = trueValueAt(t);
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
t += pollMs + Math.floor(rand() * jitterMs);
}
// Render like client.js: between polls, extrapolate from the latest
// anchor; clamp sub-1.5 s regressions while running.
const shown = []; // { t, sec, err }
let mono = null;
let p = 0;
for (let t = polls[0].t; t <= durationMs; t += 25) {
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
const { out, running } = polls[p];
if (!running || out.valueMs === null) {
mono = null;
continue;
}
let value = out.valueMs + dir * (t - out.asOf);
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
mono = value;
const truth = trueValueAt(t);
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
}
return { name, dir, shown };
}
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
from legitimate corrections (absorbing clock drift re-places a boundary
by up to TOL_MS once in a while). */
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
let regressions = 0;
let maxAbsErr = 0;
const changes = [];
for (let i = 1; i < shown.length; i += 1) {
const step = shown[i].sec - shown[i - 1].sec;
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
if (step !== 0) changes.push(shown[i].t);
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
}
// Whole dwells only (drop the partial first and trailing segments), after
// the lock settles — the first observed flip corrects the provisional
// mid-interval anchor once, stretching or squeezing a boundary in the
// timer's first moments, which no one can see on a wall. A countdown
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
// allow one ~2 s dwell; everything else must be ~1 s.
const settled = shown.length ? shown[0].t + 2500 : 0;
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
const near = (d, target) => Math.abs(d - target) <= 200;
const long = dwells.filter((d) => near(d, 2000)).length;
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
}
/* 1. Plain countdown, many sub-second phases and poll seeds. */
for (let i = 0; i < 12; i += 1) {
const phase0 = (i * 83) % 1000;
const run = simulate({
name: `countdown q=${phase0}ms seed=${i}`,
kind: 'countdown',
seed: 1000 + i,
durationMs: 30000,
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
});
assertSteady(run);
}
/* 2. Elapsed timer (counts up). */
for (let i = 0; i < 6; i += 1) {
const run = simulate({
name: `elapsed seed=${i}`,
kind: 'elapsed',
seed: 2000 + i,
durationMs: 25000,
trueValueAt: (t) => 400 + i * 137 + t,
});
assertSteady(run);
}
/* 3. Operator reset mid-run: value jumps back up to full length. */
{
const run = simulate({
name: 'reset',
kind: 'countdown',
seed: 7,
durationMs: 24000,
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
});
// After the reset the shown value must reach the new region within ~2 polls.
const after = run.shown.filter((s) => s.t >= 13400);
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
// Before and after the jump, digits stay monotonic within each phase.
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
assertSteady(pre, { maxErr: 700 });
}
/* 4. Pause, then resume: no stale-anchor explosion. */
{
const frozen = 8000;
const run = simulate({
name: 'pause/resume',
kind: 'countdown',
seed: 11,
durationMs: 26000,
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
runningAt: (t) => t < 6000 || t >= 14000,
});
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
assertSteady(resumed);
}
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
re-locks over time instead of accumulating a visible error. */
{
const run = simulate({
name: 'clock drift',
kind: 'countdown',
seed: 13,
durationMs: 120000,
trueValueAt: (t) => 130000 - t * 1.002,
});
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
// a backwards step (the separate regression check still demands zero).
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
}
/* 6. Idle timers pass through untouched. */
{
const phase = createTimerPhase();
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
}
if (failures) {
console.error(`\n${failures} of ${checks} checks FAILED`);
process.exit(1);
}
console.log(`phase.selftest: all ${checks} checks passed`);
+215 -17
View File
@@ -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:<uuid>' / 'ltc' — the same tiles.
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
@@ -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 = '';
+138
View File
@@ -0,0 +1,138 @@
/* Resi Cues — the tile.
*
* One number matters: how long until the next cue. It is drawn as large as the
* tile allows, with the cue's name above it, because on a wall the operator reads
* it from across the booth.
*
* The countdown ticks locally. The server sends the playhead with the instant it
* was true (`estMs` at `estAt`) and whether playback is running; this adds the
* elapsed time since. That is what keeps the digits moving every second while
* Resi itself only reports every few seconds, and it means a slow poll never
* shows as a stalled clock.
*/
const TICK_MS = 250; // redraw faster than a second so the digit turns on time
export default function create({ root, moduleApi }) {
let stream = null;
let ticker = null;
let state = null;
const settings = () => moduleApi.instanceSettings;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function hms(ms) {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Where the playhead is right now, extrapolated from the last server word. */
function playheadMs() {
if (!state || state.estMs === null || state.estMs === undefined) return null;
return state.estMs + (state.playing ? Date.now() - state.estAt : 0);
}
function render() {
root.textContent = '';
const set = settings();
if (!state || state.status === 'error') {
const box = el('div', 'resi-empty');
box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected'));
root.append(box);
return;
}
const here = playheadMs();
const wrap = el('div', 'resi-wrap');
if (state.next) {
if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT'));
if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue'));
const remaining = here === null ? null : state.next.ms - here;
const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining));
if (remaining !== null && remaining <= 30000) count.classList.add('is-soon');
if (!state.playing) count.classList.add('is-held');
wrap.append(count);
} else {
wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING'));
wrap.append(el('div', 'resi-cue', state.event || state.decoder || ''));
}
const foot = el('div', 'resi-foot');
if (set.showPrevious !== false && state.prev) {
foot.append(el('span', 'resi-foot-item',
`after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`));
}
if (set.showPosition !== false && here !== null) {
foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`));
}
if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused'));
if (foot.childElementCount) wrap.append(foot);
if (set.showCueList && state.cues && state.cues.length) {
const list = el('ul', 'resi-list');
for (const cue of state.cues) {
const row = el('li', 'resi-list-row');
if (here !== null && cue.ms <= here) row.classList.add('is-past');
if (state.next && cue.ms === state.next.ms) row.classList.add('is-next');
row.append(el('span', 'resi-list-name', cue.name));
row.append(el('span', 'resi-list-at', hms(cue.ms)));
list.append(row);
}
wrap.append(list);
}
root.append(wrap);
}
function applyStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server');
if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected');
if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`);
const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : '';
return moduleApi.setStatus('ok', `${state.decoder}${cadence}`);
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting');
stream = moduleApi.sse('/stream', {
state: (data) => {
try {
state = JSON.parse(data);
} catch {
return;
}
applyStatus();
render();
},
error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'),
});
ticker = setInterval(render, TICK_MS);
render();
},
stop() {
if (stream) stream.close();
if (ticker) clearInterval(ticker);
stream = null;
ticker = null;
root.textContent = '';
},
onConfigChange() {
render();
},
};
}
+91
View File
@@ -0,0 +1,91 @@
{
"id": "resi-cues",
"name": "Resi Cues",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 3 },
"configGroups": {
"Resi account": {
"help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one."
},
"Decoder": {
"help": "Which decoder to follow. The list is read from the account once the login above is saved."
},
"Timing": {
"collapsed": true,
"help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is."
}
},
"configSchema": {
"username": {
"type": "string",
"label": "Resi login",
"group": "Resi account",
"default": "",
"help": "The email address used to sign in to studio.resi.io."
},
"password": {
"type": "password",
"label": "Password",
"group": "Resi account",
"default": ""
},
"player": {
"type": "select",
"label": "Decoder",
"group": "Decoder",
"default": "",
"optionsRoute": "/decoders",
"help": "Leave unset to follow the only decoder on the account."
},
"pollSeconds": {
"type": "number",
"label": "Position poll (s)",
"group": "Timing",
"default": 1,
"help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings."
},
"cueRefreshSeconds": {
"type": "number",
"label": "Cue list refresh (s)",
"group": "Timing",
"default": 5,
"help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this."
},
"positionTrimMs": {
"type": "number",
"label": "Position trim (ms)",
"group": "Timing",
"default": 0,
"help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward."
}
},
"instanceSchema": {
"showCueName": {
"type": "switch",
"label": "Cue name",
"default": true
},
"showPrevious": {
"type": "switch",
"label": "Previous cue",
"default": true
},
"showPosition": {
"type": "switch",
"label": "Playhead",
"default": true
},
"showCueList": {
"type": "switch",
"label": "Cue list",
"default": false
}
}
}
+263
View File
@@ -0,0 +1,263 @@
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
*
* Two things live here, both free of the shell so they can be tested on their own
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
* estimator that turns its coarse, late readings into a usable position.
*
* The API is the undocumented one behind studio.resi.io. It is not supported by
* Resi and can change without notice; that is the price of there being no public
* way to read cue points at all.
*/
'use strict';
const RESI = 'https://central.resi.io';
/* ── time ──────────────────────────────────────────────────────────────── */
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
function positionToMs(position) {
if (typeof position !== 'string') return null;
const parts = position.split(':');
if (parts.length !== 3) return null;
const hh = Number(parts[0]);
const mm = Number(parts[1]);
const ss = Number(parts[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
}
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
function msToPosition(total) {
const t = Math.max(0, Math.floor(total));
const pad = (n, w) => String(n).padStart(w, '0');
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
}
/* ── the playhead estimator ────────────────────────────────────────────── */
const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
/**
* Resi republishes a decoder's `position` only every few seconds, on an irregular
* cadence, and each reading is already a little stale when it appears. Shown raw,
* a countdown built on it sits seconds behind and moves in lurches.
*
* So every reading is kept as *media time minus wall time*. While playback runs at
* 1x that difference is constant, and the LARGEST recent value is the reading that
* reached us with the least delay — the one to trust. The estimate is then simply
* "now plus that offset", which advances smoothly by itself and cannot be dragged
* backwards by a slow round trip.
*
* `now` is injected so the tests can drive a fake clock.
*/
function createPlayhead(options) {
const opts = options || {};
const trimMs = Number(opts.trimMs) || 0;
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving
let base; // the trusted offset
let lastMedia;
let lastNewAt;
let lastEst;
let seeked = false;
function note(mediaMs) {
if (!Number.isFinite(mediaMs)) return;
const t = now();
if (mediaMs !== lastMedia) {
const off = mediaMs - t;
// Anything inside the measured noise floor is noise; only a move clearly
// beyond it is a real seek. Measured rather than assumed, and remembered
// across a reset so the first reading after one is not judged blind.
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
if (base !== undefined) {
const delta = off - base;
if (Math.abs(delta) > threshold) {
offsets = []; // the window describes where we used to be
seeked = true;
// Only abandon the no-going-backwards guard for a move big enough that
// snapping is obviously right; a marginal call must not make the
// displayed playhead lurch backwards.
if (Math.abs(delta) > 10000) lastEst = undefined;
} else if (delta < 0) {
spreads = spreads.concat(-delta).slice(-20);
}
}
if (lastNewAt !== undefined) {
const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
gaps = gaps.concat(gap).slice(-8);
}
}
offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs;
lastNewAt = t;
}
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
// Always keep the newest reading even once it ages out. If the window empties
// during a long pause there is nothing left to compare against, and the resume
// reads as an ordinary reading instead of the discontinuity it is.
offsets = kept.length ? kept : offsets.slice(-1);
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
}
function read() {
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
const t = now();
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
// Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two.
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
}
let est = t + base + trimMs;
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
lastEst = est;
return { estMs: est, playing: true, cadenceMs };
}
/** True once per detected discontinuity — the caller re-reads the cue list. */
function takeSeek() {
const was = seeked;
seeked = false;
return was;
}
return { note, read, takeSeek };
}
/* ── the Resi Central API ──────────────────────────────────────────────── */
/**
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
*/
function createResiClient(options) {
const opts = options || {};
const doFetch = opts.fetch || globalThis.fetch;
let cookieHeader = '';
let token = '';
let loggedInAt = 0;
let customerId = '';
let cacheBust = true;
function headers() {
const h = { Accept: 'application/json' };
if (cookieHeader) h.Cookie = cookieHeader;
if (token) h.Authorization = `X-Bearer ${token}`;
return h;
}
async function login() {
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {};
for (const raw of setCookies) {
const first = String(raw).split(';')[0];
const eq = first.indexOf('=');
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
}
token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = Date.now();
const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId');
return me;
}
async function ensureSession() {
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
}
async function get(path) {
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (res.status === 401 || res.status === 403) {
await login();
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
return retry.json();
}
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json();
}
async function venues() {
await ensureSession();
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
}
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
* one — and if the service objects to the parameter, stop sending it. */
async function players(venueUuid) {
await ensureSession();
const path = `/api_v2.svc/users/${venueUuid}/players`;
if (cacheBust) {
try {
return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) {
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
cacheBust = false;
}
}
return (await get(path)) || [];
}
async function event(eventId) {
await ensureSession();
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
}
async function cues(profileId, eventId) {
await ensureSession();
const raw = (await get(
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
return raw
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
.filter((c) => c.ms !== null)
.sort((a, b) => a.ms - b.ms);
}
return {
login,
ensureSession,
venues,
players,
event,
cues,
get customerId() { return customerId; },
};
}
/** The cue after `estMs`, and the one before it. */
function surroundingCues(cues, estMs) {
let prev = null;
let next = null;
for (const c of cues || []) {
if (c.ms > estMs) { next = c; break; }
prev = c;
}
return { prev, next };
}
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
+210
View File
@@ -0,0 +1,210 @@
'use strict';
/**
* Unit tests for the Resi playhead estimator.
*
* node modules/resi-cues/resi.test.js
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
*
* Plain Node, no dependencies; exits non-zero on the first failure.
*
* The estimator's whole job is to be right about a feed that is coarse, late and
* irregular, so the fixture is a simulated Resi rather than a list of values: a
* decoder playing at 1x, and an API that republishes its position every so often,
* already stale, over a round trip that varies. Nine profiles span what the real
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
* against one, three separate breakages of this code go unnoticed.
*/
const assert = require('assert');
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── the simulated world ───────────────────────────────────────────────── */
function makeWorld({ cadence, lag, jitter, net }) {
let seed = 12345;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const w = {
t: 1700000000000,
media: 8 * 60000 + 48000,
playing: true,
published: 0,
lastPublish: 0,
nextCadence: cadence,
};
w.published = w.media - lag;
w.lastPublish = w.t;
w.advance = (ms) => {
for (let i = 0; i < ms; i += 100) {
w.t += 100;
if (w.playing) w.media += 100;
if (w.t - w.lastPublish >= w.nextCadence) {
w.lastPublish = w.t;
w.published = w.media - lag;
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
}
}
};
// A poll reads what Resi had when the request left, and the round trip costs time.
w.poll = () => {
const body = w.published;
if (net) w.advance(100 + Math.round(rnd() * net));
return body;
};
return w;
}
const CUES = [
{ name: 'Welcome', ms: 5 * 60000 },
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
{ name: 'Message', ms: 41 * 60000 },
{ name: 'Closing', ms: 45 * 60000 },
];
function runProfile(cadence, lag, jitter, net) {
const w = makeWorld({ cadence, lag, jitter, net });
const head = createPlayhead({ now: () => w.t });
const tol = lag + 1200 + jitter + net * 0.35;
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
seconds(40);
let worstEst = 0;
let worstRaw = 0;
for (let i = 0; i < 30; i++) {
const { estMs } = second();
worstEst = Math.max(worstEst, w.media - estMs);
worstRaw = Math.max(worstRaw, w.media - w.published);
}
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
if (cadence >= 2000) {
assert.ok(worstRaw >= cadence * 0.4,
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
}
assert.ok(head.read().playing, 'should read as playing');
});
test(`${label}: ticks every second and never runs backwards`, () => {
let prev = head.read().estMs;
let stalls = 0;
let backwards = 0;
for (let i = 0; i < 200; i++) {
const { estMs } = second();
if (estMs === prev) stalls++;
if (estMs < prev) backwards++;
prev = estMs;
}
assert.strictEqual(stalls, 0, 'the playhead stalled');
assert.strictEqual(backwards, 0, 'the playhead went backwards');
});
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
let reported = false;
for (let i = 0; i < 60 && !reported; i++) {
second();
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
}
assert.ok(reported, 'the jump was never picked up');
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
`resynced ${Math.round(w.media - estMs)}ms off`);
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
const after = seconds(15);
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
});
test(`${label}: follows a seek backwards`, () => {
w.media = 6 * 60000;
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
});
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
w.playing = false;
const stoppedAt = w.media;
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
+ cadence + jitter + 6000) / 1000);
let r = seconds(wait);
assert.strictEqual(r.playing, false, 'did not notice the pause');
const held = r.estMs;
r = seconds(20);
assert.strictEqual(r.estMs, held, 'kept counting while paused');
w.playing = true;
r = seconds(25);
assert.strictEqual(r.playing, true, 'did not notice the resume');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
'the frozen position crept instead of stopping');
});
test(`${label}: survives a pause longer than the offset window`, () => {
w.playing = false;
let r = seconds(150);
assert.strictEqual(r.playing, false, 'should still read as paused');
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
w.playing = true;
head.takeSeek();
r = seconds(30);
assert.strictEqual(r.playing, true, 'did not recover');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
});
}
/* ── the plain bits ────────────────────────────────────────────────────── */
test('positionToMs reads Resi positions, and rejects anything else', () => {
assert.strictEqual(positionToMs('00:00:00.000'), 0);
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
assert.strictEqual(positionToMs('00:00:01'), 1000);
assert.strictEqual(positionToMs(''), null);
assert.strictEqual(positionToMs('garbage'), null);
assert.strictEqual(positionToMs(null), null);
assert.strictEqual(positionToMs(undefined), null);
});
test('msToPosition round-trips', () => {
for (const ms of [0, 1500, 3723500, 36000000]) {
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
}
});
test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
];
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
console.log(`\n${passed} tests passed`);
+356
View File
@@ -0,0 +1,356 @@
/* Resi Cues — server entry.
*
* Follows one Resi decoder and works out how long until its next cue point, then
* offers that two ways:
*
* GET /state → snapshot for this module's tile
* GET /stream → SSE, `state` events on every change
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
* GET /decoders → optionsRoute for the admin page's decoder picker
*
* Both the cue list and the playhead come from Resi's cloud rather than the
* decoder: the decoder reports its position upstream and central.resi.io hands it
* back. Nothing is installed at the decoder end, and nothing here talks to the
* decoder directly.
*
* Why the playhead is estimated rather than repeated is explained in resi.js.
*/
'use strict';
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
const HEARTBEAT_MS = 15000;
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
}
function sseHead(res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
}
function writeAll(set, chunk) {
for (const res of set) {
try {
res.write(chunk);
} catch {
set.delete(res);
}
}
}
/** What the tile and the Timers module see before anything has been configured. */
function emptyState(message) {
return {
status: 'error',
message: message || 'Not configured',
decoder: '',
event: '',
playing: false,
estMs: null,
estAt: Date.now(),
cadenceMs: 0,
next: null,
prev: null,
cues: [],
};
}
function emptyTimers(now) {
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
}
let current = null; // the live handle, shared with routes()
module.exports = {
init({ config, log }) {
const username = String(config.username || '').trim();
const password = String(config.password || '');
const wanted = String(config.player || '').trim();
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
const stateStreams = new Set();
const timerStreams = new Set();
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
let timer = null;
let heartbeat = null;
let stopped = false;
const resi = createResiClient({ username, password });
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
let venueUuid = '';
let hardwareId = '';
let decoderName = '';
let eventId = '';
let profileId = '';
let eventName = '';
let cues = [];
let lastCueLoad = 0;
let inFlight = false;
/* — what the two consumers see — */
function publishState(next) {
const before = JSON.stringify(state);
state = next;
if (JSON.stringify(state) === before) return;
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
/**
* The Timers module extrapolates from `startedAt` and its own clock, so this
* only has to change when a timer changes *structurally* — a new cue, a pause,
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
* for nothing.
*/
function timersSignature(snap) {
return JSON.stringify(snap.timers.map(
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
}
let lastTimerSig = '';
function publishTimers() {
const snap = timersSnapshot(Date.now());
const sig = timersSignature(snap);
if (sig === lastTimerSig) return;
lastTimerSig = sig;
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot(now) {
const timers = [];
if (state.estMs !== null && state.estMs !== undefined) {
const est = state.estMs + (state.playing ? now - state.estAt : 0);
if (state.next) {
const remainingMs = Math.max(0, state.next.ms - est);
// The countdown "runs" from the cue before it, which is what gives the
// consumer something to colour progress against.
const from = state.prev ? state.prev.ms : 0;
timers.push({
id: 'next-cue',
label: state.next.name || 'Next cue',
kind: 'countdown',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est - from),
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
timers.push({
id: 'playhead',
label: 'Playhead',
kind: 'elapsed',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est),
elapsedMs: Math.max(0, est),
remainingMs: null,
targetMs: null,
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
detail: state.prev ? `After ${state.prev.name}` : '',
});
}
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
}
/* — finding the decoder — */
async function locate() {
const venues = await resi.venues();
for (const v of venues) {
const players = await resi.players(v.uuid);
for (const p of players) {
if (wanted ? p.playerHardwareId === wanted : true) {
venueUuid = v.uuid;
hardwareId = p.playerHardwareId;
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
return true;
}
}
}
return false;
}
/* — the loop — */
async function tick() {
if (inFlight || stopped) return;
inFlight = true;
try {
if (!hardwareId && !(await locate())) {
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
return;
}
const players = await resi.players(venueUuid);
const me = players.find((p) => p.playerHardwareId === hardwareId);
if (!me) {
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
return;
}
head.note(positionToMs(me.position));
if (me.eventId && me.eventId !== eventId) {
eventId = me.eventId;
const ev = (await resi.event(eventId)) || {};
profileId = ev.eventProfileId || '';
eventName = ev.name || '';
cues = profileId ? await resi.cues(profileId, eventId) : [];
lastCueLoad = Date.now();
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
} else if (!me.eventId) {
eventId = '';
profileId = '';
eventName = '';
cues = [];
}
const seeked = head.takeSeek();
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
lastCueLoad = Date.now();
const fresh = await resi.cues(profileId, eventId);
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
log(`cue list changed — ${fresh.length} cue(s)`);
cues = fresh;
}
}
const { estMs, playing, cadenceMs } = head.read();
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
publishState({
status: 'ok',
message: '',
decoder: decoderName,
event: eventName,
playing,
estMs,
estAt: Date.now(),
cadenceMs,
next,
prev,
cues,
});
} catch (err) {
const message = (err && err.message) || String(err);
log(`poll failed: ${message}`);
publishState({ ...state, status: 'error', message });
} finally {
inFlight = false;
publishTimers();
}
}
if (username && password) {
timer = setInterval(() => { void tick(); }, pollMs);
void tick();
} else {
log('no Resi login configured — idle');
}
heartbeat = setInterval(() => {
writeAll(stateStreams, ': ping\n\n');
writeAll(timerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
current = {
get state() { return state; },
timersSnapshot,
stateStreams,
timerStreams,
resi,
configured: Boolean(username && password),
stop() {
stopped = true;
if (timer) clearInterval(timer);
if (heartbeat) clearInterval(heartbeat);
for (const res of [...stateStreams, ...timerStreams]) {
try { res.end(); } catch { /* already gone */ }
}
stateStreams.clear();
timerStreams.clear();
if (current && current.stop === this.stop) current = null;
},
health() {
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
if (state.status === 'ok') {
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
}
if (state.status === 'error') return { status: 'error', message: state.message };
return { status: 'connecting', message: state.message };
},
};
return current;
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, current ? current.state : emptyState('Module restarting'));
},
'GET /stream': (req, res) => {
sseHead(res);
const snap = current ? current.state : emptyState('Module restarting');
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.stateStreams.add(res);
req.on('close', () => current && current.stateStreams.delete(res));
},
'GET /timers': (req, res) => {
const now = Date.now();
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
},
'GET /timers/stream': (req, res) => {
sseHead(res);
const now = Date.now();
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.timerStreams.add(res);
req.on('close', () => current && current.timerStreams.delete(res));
},
// The admin page's decoder picker. A live call, because it is how the decoder
// gets chosen in the first place — there is nothing cached to answer from.
'GET /decoders': async (req, res) => {
if (!config.username || !config.password) {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
const client = createResiClient({
username: String(config.username), password: String(config.password),
});
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
options.push({
value: p.playerHardwareId,
label: `${p.userName || p.playerHardwareId}`,
group: v.userName || v.name || 'Venue',
});
}
}
json(res, 200, { options });
} catch (err) {
log(`decoder list failed: ${(err && err.message) || err}`);
json(res, 502, { options: [], error: (err && err.message) || String(err) });
}
},
};
},
};
+201
View File
@@ -0,0 +1,201 @@
'use strict';
/**
* Server-entry tests for Resi Cues.
*
* node modules/resi-cues/server.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Resi is
* replaced with a canned account — a venue, a decoder on an event, four cue
* points — so this covers the parts `resi.test.js` cannot: signing in, finding
* the decoder, following the event, and the two snapshots other things read
* (`/state` for the tile, `/timers` for the Timers module).
*
* Nothing here reaches the network, and the poll interval is captured rather
* than run, so the test drives the loop a tick at a time.
*/
const assert = require('assert');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── a canned Resi account ─────────────────────────────────────────────── */
const CUES = [
{ name: 'Welcome', position: '00:05:00.000' },
{ name: 'Campus Host', position: '00:12:30.000' },
{ name: 'Message', position: '00:41:00.000' },
];
const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 };
function reply(body, extra) {
return Object.assign({
ok: true,
status: 200,
headers: { get: () => null, getSetCookie: () => [] },
json: async () => body,
}, extra || {});
}
globalThis.fetch = async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
world.logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]);
if (path === '/api_v2.svc/users/V1/players') {
world.playerCalls++;
return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]);
}
if (path === '/api/v3/customers/CUST/events/EV1') {
return reply({ name: 'Sunday 9am', eventProfileId: 'P1' });
}
if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES);
return reply({}, { ok: false, status: 404 });
};
/* ── the shell, as much of it as the module touches ────────────────────── */
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
let tickFn = null;
globalThis.setInterval = (fn, ms) => {
if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one
return { fn, ms };
};
globalThis.clearInterval = () => {};
const server = require('./server');
const logged = [];
const handle = server.init({
config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 },
log: (m) => logged.push(m),
});
/** Let the poll's promise chain finish; nothing here waits on real time. */
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function collect(route, req) {
const res = {
code: 0, headers: null, body: '',
writeHead(code, headers) { this.code = code; this.headers = headers; },
write(chunk) { this.body += chunk; },
end(chunk) { if (chunk) this.body += chunk; },
};
route(req || { on() {} }, res);
return res;
}
(async () => {
await settle();
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message);
assert.strictEqual(state.decoder, 'Main — Lobby');
assert.strictEqual(state.event, 'Sunday 9am');
assert.strictEqual(state.cues.length, 3);
});
test('works out the cue either side of the playhead', () => {
const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
});
test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
});
test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
assert.ok(res.body.includes('retry: 3000'), 'should set a retry');
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
});
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi');
assert.ok(snap.updatedAt > 0);
const [next, playhead] = snap.timers;
assert.strictEqual(next.id, 'next-cue');
assert.strictEqual(next.kind, 'countdown');
assert.strictEqual(next.state, 'running');
assert.strictEqual(next.label, 'Campus Host');
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Campus Host');
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead');
assert.strictEqual(playhead.kind, 'elapsed');
assert.strictEqual(playhead.remainingMs, null);
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
});
test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host
tickFn();
await settle();
const state = handle.state;
assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message');
});
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = '';
tickFn();
await settle();
assert.strictEqual(handle.state.next, null);
assert.deepStrictEqual(handle.state.cues, []);
assert.strictEqual(handle.state.event, '');
});
test('health speaks about the upstream, not about itself', () => {
const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful');
});
test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins;
const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
assert.strictEqual(idle.health().status, 'error');
assert.ok(/login/i.test(idle.health().message), idle.health().message);
idle.stop();
});
test('stop() leaves nothing running', () => {
handle.stop();
const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error');
});
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`);
})();
+113
View File
@@ -0,0 +1,113 @@
/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with
the shell's CSS variables only.
The countdown is sized in cqmin so it fills whatever the tile is given: the
same module is a small strip on a busy dashboard and a wall panel on its own. */
[data-module="resi-cues"] {
container-type: size;
}
[data-module="resi-cues"] .resi-wrap {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35em;
padding: 2%;
text-align: center;
overflow: hidden;
}
[data-module="resi-cues"] .resi-label {
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 1rem);
font-weight: 700;
letter-spacing: 0.14em;
line-height: 1;
}
[data-module="resi-cues"] .resi-cue {
color: var(--text);
font-size: clamp(0.85rem, 11cqmin, 2.6rem);
font-weight: 650;
line-height: 1.05;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-count {
color: var(--accent);
font-size: clamp(1.4rem, 30cqmin, 9rem);
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1;
}
[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); }
/* Held rather than counting: the digits are still true, they just are not moving. */
[data-module="resi-cues"] .resi-count.is-held,
[data-module="resi-cues"] .resi-foot-item.is-held {
color: var(--muted);
}
[data-module="resi-cues"] .resi-foot {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2em 0.9em;
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 0.95rem);
font-variant-numeric: tabular-nums;
}
[data-module="resi-cues"] .resi-list {
list-style: none;
margin: 0.4em 0 0;
padding: 0.4em 0 0;
width: 100%;
max-width: 28em;
border-top: 1px solid var(--border);
overflow-y: auto;
font-size: clamp(0.6rem, 4.5cqmin, 0.95rem);
}
[data-module="resi-cues"] .resi-list-row {
display: flex;
justify-content: space-between;
gap: 1em;
padding: 0.12em 0.2em;
color: var(--text);
}
[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); }
[data-module="resi-cues"] .resi-list-row.is-next {
color: var(--accent);
font-weight: 650;
}
[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; }
[data-module="resi-cues"] .resi-list-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 6%;
color: var(--muted);
text-align: center;
}
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.4.1",
"version": "0.5.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+48
View File
@@ -0,0 +1,48 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
# from spl-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports measurement off.
set -e -u
cd "$(dirname "$0")"
NAME="spl-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -8,10 +8,11 @@
// measurement is done here and only the answers cross the pipe.
//
// Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build:
// code, no package manager. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
// modules/spl-meter/native/build.sh (both architectures)
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
+14 -2
View File
@@ -20,7 +20,7 @@
*/
const path = require('path');
const { createSplListener } = require('./spl-listener');
const { createSplListener, captureTool } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
@@ -114,11 +114,16 @@ module.exports = {
error: '',
streams: new Set(),
silentSince: null,
toolMissing: '',
listener: null,
lastSent: '',
pinger: null,
};
// The capture tool ships prebuilt in the module (bin/); with none for
// this platform every reading is off and the health line says so.
state.toolMissing = captureTool(moduleDir).missing;
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
@@ -177,6 +182,7 @@ module.exports = {
return {
stop() { teardown(); },
health() {
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
@@ -300,6 +306,12 @@ 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 tool = captureTool(__dirname);
if (tool.missing) {
log(`device list unavailable — ${tool.missing}`);
json(res, 200, { options: [], error: tool.missing });
return;
}
try {
const devices = await listDevices();
json(res, 200, {
@@ -429,7 +441,7 @@ function teardown() {
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
+42 -73
View File
@@ -3,7 +3,8 @@
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more.
@@ -13,11 +14,11 @@
* { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* 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.
* The capture tool ships prebuilt inside the module, one binary per
* platform under bin/ (module guide, "Native helpers"), exactly like the
* Timers module's ltc-capture: nothing is ever built on the machine running
* ProdDash. captureTool() picks the binary for this platform; when there is
* none, measurement reports itself off and the rest of the module carries on.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -30,6 +31,33 @@ const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** What the module ships in bin/, by platform and architecture. */
const HELPERS = {
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/**
* The capture tool for this machine. It is never built here and never
* looked for anywhere else: the module ships it, or measurement is off on
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
*
* @returns {{bin:string, missing:string}}
*/
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
return {
bin: '',
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
};
}
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) —
retrying every 5 s would only fill the log with the same sentence. */
@@ -45,7 +73,6 @@ const STALL_MS = 3000;
*/
function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null;
let stopped = false;
@@ -53,66 +80,8 @@ 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.
*/
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';
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -132,12 +101,12 @@ function createSplListener(opts) {
function start() {
if (stopped) return;
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);
const tool = captureTool(moduleDir);
if (tool.missing) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
onUpdate({ error: tool.missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -153,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
@@ -236,4 +205,4 @@ function createSplListener(opts) {
};
}
module.exports = { createSplListener };
module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -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",
+1
View File
@@ -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; }
+43
View File
@@ -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;
+1
View File
@@ -58,6 +58,7 @@
<section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1>
<div class="catalog-toolbar">
<button class="btn small primary" id="install-selected" hidden>Install selected</button>
<button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span>
</div>
+10
View File
@@ -510,6 +510,7 @@ function reconcileOfferedTiles() {
}
if (changed) rebuildDividers();
scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something
}
/* A module's entry list can change without anyone saving admin config: PCO
@@ -676,6 +677,15 @@ function buildTile(tile) {
function refreshEmptyState() {
emptyEl.hidden = tiles.length > 0;
if (emptyEl.hidden) return;
// Two different empties: a bare board, and a ProdDash with nothing to put
// on one — a packaged install starts without modules until Admin adds some.
const noModules = registry.size === 0;
if (noModules === emptyEl.classList.contains('no-modules')) return;
emptyEl.classList.toggle('no-modules', noModules);
emptyEl.innerHTML = noModules
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
}
/* ── drag to move ───────────────────────────────────────────────────── */
+137 -1
View File
@@ -277,6 +277,52 @@ function isUninstalled(id) {
return moduleState(id).uninstalled === true;
}
/**
* A packaged install that still carries modules in its app folder — every
* install made before 1.10.0 does — moves them into the data directory
* once, as installed copies, and drops the app-folder copies. Nothing
* changes for the operator: the same modules run with the same settings
* (config is keyed by module id), and from now on each updates on its own
* from the catalog. A compiled helper sitting in a bundled folder travels
* with it. A module the operator had uninstalled is simply dropped — the
* catalog still offers it, and its settings stay in modules.json.
*/
function unbundleModules() {
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
let moved = 0;
let failed = 0;
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
if (isUninstalled(man.id)) continue;
const copy = installed.get(man.id);
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
try {
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
fs.rmSync(staging, { recursive: true, force: true });
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
moved += 1;
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
} catch (err) {
failed += 1;
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
}
}
if (failed) {
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
return;
}
try {
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
} catch (err) {
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
}
}
function moduleState(id) {
const s = modulesConfig[id];
return s && typeof s === 'object' ? s : {};
@@ -373,7 +419,18 @@ function mountModule(id) {
const log = makeLog(id);
try {
const serverPath = path.join(man.dir, man.server);
// Re-require fresh config on every (re)mount; the module code itself stays cached.
// Drop the module's code from the require cache so every (re)mount runs
// the files on disk. Without this a module UPDATE swaps the folder while
// the process keeps executing the old code until ProdDash itself
// restarts — the manifest (read fresh from disk) then reports a version
// the running code isn't, and anything new in the update silently
// doesn't happen. Modules already have to come up clean on re-init
// (module guide), so a fresh load on config remounts is the same
// contract without the trap.
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
for (const key of Object.keys(require.cache)) {
if (key.startsWith(cachePrefix)) delete require.cache[key];
}
const mod = require(serverPath);
const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
@@ -1060,13 +1117,22 @@ async function installModule(id) {
if (download) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set();
for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
shipped.add(rel);
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in
// by hand on a machine whose toolchain can't build it — are gitignored,
// so the repo never ships them. Replacing the folder must not destroy
// them: carry executables the download doesn't include over from the
// copy being replaced (or from the app's bundled copy on a first
// install), or "update" quietly kills a working module.
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
unmountModule(id);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
@@ -1083,6 +1149,43 @@ async function installModule(id) {
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
}
/**
* Copy executable files (any execute bit) that `shipped` does not include
* from the first existing source dir into `staging`, keeping their relative
* path and mode. Executables only, on purpose: they are the per-machine
* build artifacts (gitignored, so never in a download), while stray text
* files from an old version — code an update deliberately removed — must
* NOT be resurrected.
*/
function carryOverExecutables(sourceDirs, staging, shipped) {
const from = sourceDirs.find((d) => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
if (!from) return;
const walk = (rel) => {
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(relPath);
continue;
}
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
try {
const stat = fs.statSync(path.join(from, relPath));
if (!(stat.mode & 0o111)) continue;
const target = path.join(staging, relPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(from, relPath), target);
fs.chmodSync(target, stat.mode);
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
} catch (err) {
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
}
}
};
walk('');
}
/** Remove a module: an installed copy is deleted; a bundled one is switched
off and hidden (its folder belongs to the app). Settings are kept so a
later reinstall picks them up again. */
@@ -1178,9 +1281,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
return rel.split(path.sep).join('/') + '/';
})();
/* A packaged install — the macOS app, the Windows bundle: anything that is
not a git checkout, the same test the updater uses to choose the archive
over git pull — carries no modules of its own. Every module comes from
the catalog into the data directory, and the shell's own update never
brings one along. A checkout keeps running the modules in its tree, which
is what development wants. */
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
@@ -1523,6 +1635,29 @@ async function handleAdminApi(req, res, urlPath) {
return;
}
// Several at once — a fresh install picking its set. One result per id;
// a failure stops nothing else.
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
const results = [];
for (const id of ids) {
try {
results.push({ ok: true, ...(await installModule(id)) });
} catch (err) {
results.push({ ok: false, id, error: err?.message || String(err) });
}
}
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
}
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
if (install && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
@@ -2017,6 +2152,7 @@ server.requestTimeout = 0;
/* ── boot ───────────────────────────────────────────────────────────── */
unbundleModules();
discoverModules();
mountAllModules();
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
'use strict';
/**
* A pretend AbleSet for testing the ableset module without the booth rig.
* Speaks AbleSet's OSC API over UDP: answers /subscribe, /getValues and
* /unsubscribe, sends /heartbeat every 2 s and pushes value changes while a
* setlist plays through — sections, a tempo per song, a section that loops
* twice, a song queued out of order, and the time left in song and set.
*
* node tools/ableset-mock.js [port] (default 39051)
* --stopped start with the transport stopped (press nothing; it stays)
* --no-live report Ableton Live as not connected
* --speed <x> play x times faster than real time (default 1)
*
* Point the AbleSet module at host 127.0.0.1 and this port.
*/
const dgram = require('dgram');
const { encodeMessage, decodePacket } = require('../modules/ableset/osc');
const argv = process.argv.slice(2);
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 39051;
const SPEED = Number(argv[argv.indexOf('--speed') + 1]) > 0 && argv.includes('--speed') ? Number(argv[argv.indexOf('--speed') + 1]) : 1;
let playing = !argv.includes('--stopped');
const live = !argv.includes('--no-live');
// name, tempo, [section name, bars] …
const SONGS = [
['1- CALL TO WORSHIP', 72, [['INTRO', 4], ['READING', 12], ['OUTRO', 4]]],
['2 -ALLELUIA - A', 125, [['INTRO', 8], ['CHORUS', 8], ['VERSE 1', 16], ['PRE-CHORUS', 8], ['CHORUS', 16], ['BRIDGE', 16], ['CHORUS', 16], ['ENDING', 8]]],
['3- WHAT A GOD - A', 76.81, [['INTRO', 4], ['VERSE 1', 16], ['CHORUS', 8], ['VERSE 2', 16], ['CHORUS', 8], ['BRIDGE', 8], ['BRIDGE', 8], ['CHORUS', 16], ['TAG', 4], ['ENDING', 8]]],
['4- The Blood - Gb', 68, [['INTRO', 4], ['VERSE 1', 8], ['CHORUS', 8], ['VERSE 2', 8], ['CHORUS', 8], ['OUTRO', 4]]],
['LOOP', 100, [['LOOP', 16]]],
['5- BE PRAISED- Db', 140, [['INTRO', 8], ['VERSE', 16], ['CHORUS', 16], ['INSTRUMENTAL', 8], ['CHORUS', 16], ['ENDING', 4]], '6/8'],
];
const COLORS = ['default', 'blue', 'default', 'red', 'default', 'purple'];
// Lay the songs end to end on one arrangement, as AbleSet does.
let cursor = 8;
const songs = SONGS.map(([name, tempo, parts, sig], i) => {
const [n, d] = (sig || '4/4').split('/').map(Number);
const bpb = (n * 4) / d;
const start = cursor;
const sections = parts.map(([sname, bars]) => {
const s = { name: sname, start: cursor, end: cursor + bars * bpb };
cursor = s.end;
return s;
});
return { name, tempo, sig: [n, d], start, end: cursor, sections, color: COLORS[i] || 'default' };
});
const secs = (song, beats) => (beats * 60) / song.tempo;
const songDuration = (song) => secs(song, song.end - song.start);
let songIdx = 1;
let beats = songs[songIdx].start + 40; // a little way into the second song
let loopsLeft = 0; // section-loop repeats still to play
let queued = null; // index of a song queued out of order
const subscribers = new Map(); // "ip:port" → { ip, port }
const sock = dgram.createSocket('udp4');
const sent = new Map();
function values() {
const song = songs[songIdx];
const sIdx = Math.max(0, song.sections.findIndex((s) => beats >= s.start && beats < s.end));
const sect = song.sections[sIdx];
const nextSect = song.sections[sIdx + 1] || null;
const nextSongIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : -1;
const nextSong = songs[nextSongIdx] || null;
const leftInSong = secs(song, song.end - beats);
const leftInSet = leftInSong + songs.slice(songIdx + 1).reduce((a, s) => a + songDuration(s), 0);
const col = (name) => [name, name === 'default' ? '#FFFFFF' : { blue: '#3B82F6', red: '#EF4444', purple: '#A855F7' }[name] || '#FFFFFF'];
return {
'/global/isConnected': [live ? 1 : 0],
'/global/isLoadingFile': [0],
'/global/isPlaying': [playing ? 1 : 0],
'/global/isRecording': [0],
'/global/tempo': [song.tempo],
'/global/timeSignature': song.sig,
'/global/beatsPosition': [Math.floor(beats)],
'/global/finePosition': [beats],
'/setlist/name': ['260927 AB (mock)'],
'/setlist/songs': songs.map((s) => s.name),
'/setlist/songDurations': songs.map(songDuration),
'/setlist/songColors': songs.map((s) => s.color),
'/setlist/sections': song.sections.map((s) => s.name),
'/setlist/sectionColors': song.sections.map((s) => (s.name === 'BRIDGE' ? 'purple' : 'default')),
'/setlist/activeSongName': [song.name],
'/setlist/activeSongIndex': [songIdx],
'/setlist/activeSongStart': [song.start],
'/setlist/activeSongEnd': [song.end],
'/setlist/activeSongDuration': [songDuration(song)],
'/setlist/activeSongColor': col(song.color),
'/setlist/activeSectionName': [sect.name],
'/setlist/activeSectionIndex': [sIdx],
'/setlist/activeSectionStart': [sect.start],
'/setlist/activeSectionEnd': [sect.end],
'/setlist/activeSectionDuration': [secs(song, sect.end - sect.start)],
'/setlist/activeSectionColor': col(sect.name === 'BRIDGE' ? 'purple' : 'default'),
'/setlist/activeSectionLoopMode': [loopsLeft > 0 ? 'loop' : null],
'/setlist/activeSectionLoopCount': [loopsLeft],
'/setlist/isInActiveSectionLoop': [loopsLeft > 0 ? 1 : 0],
'/setlist/nextSectionName': [nextSect ? nextSect.name : null],
'/setlist/nextSectionIndex': [nextSect ? sIdx + 1 : -1],
'/setlist/nextSectionStart': [nextSect ? nextSect.start : null],
'/setlist/nextSectionEnd': [nextSect ? nextSect.end : null],
'/setlist/nextSongName': [nextSong ? nextSong.name : null],
'/setlist/nextSongIndex': [nextSongIdx],
'/setlist/nextSongDuration': [nextSong ? songDuration(nextSong) : null],
'/setlist/nextSongColor': col(nextSong ? nextSong.color : 'default'),
'/setlist/queuedName': [queued != null ? songs[queued].name : null, null],
'/setlist/queuedIndex': [queued != null ? queued : -1, -1],
'/setlist/queuedType': [queued != null ? 'song' : null],
'/setlist/loopEnabled': [0],
'/setlist/isCountingIn': [0],
'/setlist/remainingTimeInSong': [Math.round(leftInSong)],
'/setlist/remainingTimeInSet': [Math.round(leftInSet)],
'/audioInterfaces/connected': [1],
'/audioInterfaces/count': [1],
'/audioInterfaces/names': ['PlayAUDIO12'],
'/audioInterfaces/status': ['sceneA'],
'/version/app': ['mock'],
};
}
function sendTo(sub, address, args) {
sock.send(encodeMessage(address, args), sub.port, sub.ip); // null goes out as nil, as AbleSet sends it
}
function push(force = false) {
const now = values();
for (const [address, args] of Object.entries(now)) {
const key = JSON.stringify(args);
if (!force && sent.get(address) === key && address !== '/global/finePosition') continue;
if (address === '/global/finePosition' && !playing && !force) continue;
sent.set(address, key);
for (const sub of subscribers.values()) sendTo(sub, address, args);
}
}
sock.on('message', (buf, rinfo) => {
let msgs;
try { msgs = decodePacket(buf); } catch { return; }
for (const { address, args } of msgs) {
const ip = args[0] === 'auto' || !args[0] ? rinfo.address : String(args[0]);
const port = Number(args[1]) || rinfo.port;
if (address === '/subscribe') {
subscribers.set(`${ip}:${port}`, { ip, port });
sendTo({ ip, port }, '/subscribed', []);
console.log(`subscribed ${ip}:${port} (${args[2] || 'no name'})`);
} else if (address === '/unsubscribe') {
subscribers.delete(`${ip}:${port}`);
console.log(`unsubscribed ${ip}:${port}`);
} else if (address === '/getValues') {
const sub = { ip, port };
for (const [a, v] of Object.entries(values())) sendTo(sub, a, v);
}
}
});
setInterval(() => {
for (const sub of subscribers.values()) sendTo(sub, '/heartbeat', []);
}, 2000);
let last = Date.now();
setInterval(() => {
const now = Date.now();
const dtSec = ((now - last) / 1000) * SPEED;
last = now;
if (playing) {
const song = songs[songIdx];
const before = song.sections.find((s) => beats >= s.start && beats < s.end);
beats += dtSec * (song.tempo / 60);
// The second BRIDGE of song 3 loops twice before it lets go.
if (before && beats >= before.end && loopsLeft > 0) {
loopsLeft -= 1;
beats = before.start + (beats - before.end);
}
const after = song.sections.find((s) => beats >= s.start && beats < s.end);
if (after && after !== before && after.name === 'BRIDGE' && song.sections.indexOf(after) === 6) loopsLeft = 2;
if (beats >= song.end) {
// Stop at the end of each song, as a service does, then carry on.
const nextIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : 0;
queued = null;
songIdx = nextIdx;
beats = songs[songIdx].start;
playing = false;
push();
setTimeout(() => { playing = true; last = Date.now(); push(); }, 4000 / SPEED);
// Halfway through the set someone queues the last song early.
if (songIdx === 3) setTimeout(() => { queued = 5; push(); }, 8000 / SPEED);
}
}
push();
}, 100);
sock.bind(PORT, () => console.log(`AbleSet mock on udp ${PORT}${playing ? '' : ' (stopped)'}${live ? '' : ' (Live not connected)'}${SPEED !== 1 ? ` at ${SPEED}×` : ''}`));
+134
View File
@@ -11,6 +11,8 @@
* GET /services/v2/service_types every service type, with its
* parent folder relationship
* GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/teams every team, including one
* nobody is scheduled on
* GET /services/v2/service_types/{id}/item_note_categories
* the note categories the
* items below use, plus one
@@ -25,6 +27,15 @@
* "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
* records + Team / Person, with
* confirmed / unconfirmed /
* declined statuses, a team
* nobody is on, and people with
* and without a photo
* GET /avatars/{personId}.svg those photos (initials on a
* disc) — the module proxies
* them like the real CDN
*
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching.
@@ -194,6 +205,107 @@ function itemsDocument(planId, include) {
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
/**
* Who is scheduled, as Planning Center returns it from
* /plans/{id}/team_members?include=team,person: teams in their own order,
* people with a position and a scheduling status ("C" confirmed,
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
* has no picture for — the tile draws their initials instead.
*/
const TEAMS = [
{
id: 't1', name: 'Worship', sequence: 1, members: [
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
],
},
{
id: 't2', name: 'Production', sequence: 2, members: [
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
],
},
{
id: 't3', name: 'Hosting', sequence: 3, members: [
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
],
},
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
];
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
function avatarSvg(name) {
const words = String(name).trim().split(/\s+/);
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
}
function teamMembersDocument(planId, include, port) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
const included = [];
const seenTeams = new Set();
for (const team of TEAMS) {
for (const m of team.members) {
data.push({
type: 'PlanPerson',
id: m.id,
attributes: {
name: m.name,
status: m.status,
team_position_name: m.position,
notes: m.notes || null,
decline_reason: m.decline || null,
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
status_updated_at: iso(bootedAt - 3600000),
},
relationships: {
plan: { data: { type: 'Plan', id: planId } },
team: { data: { type: 'Team', id: team.id } },
person: { data: { type: 'Person', id: m.person } },
},
});
if (wants.has('person')) {
included.push({
type: 'Person',
id: m.person,
attributes: {
name: m.name,
first_name: m.name.split(' ')[0],
last_name: m.name.split(' ').slice(-1)[0],
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
},
});
}
if (wants.has('team') && !seenTeams.has(team.id)) {
seenTeams.add(team.id);
included.push({
type: 'Team',
id: team.id,
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
});
}
}
}
return { data, included };
}
function folderRecord(f) {
return {
type: 'Folder',
@@ -242,6 +354,16 @@ const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
// Photos are plain images on Planning Center's CDN — no auth there, none here.
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
if (avatar) {
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
if (!person) return notFound(res);
const body = avatarSvg(person.name);
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
return void res.end(body);
}
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
}
@@ -266,6 +388,13 @@ const server = http.createServer((req, res) => {
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] === 'teams' && parts.length === 3) {
return collection(res, TEAMS.map((t) => ({
type: 'Team', id: t.id,
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
})), url);
}
if (parts[2] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
}
@@ -286,6 +415,11 @@ const server = http.createServer((req, res) => {
if (!plan) return notFound(res);
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
if (parts[4] === 'team_members') {
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
doc.links = { self: url.pathname + url.search };
return send(res, 200, doc);
}
if (parts[4] === 'items') {
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
doc.links = { self: url.pathname + url.search };
+29
View File
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
sendJson(res, { error: 'not found' }, 404);
});
/* ── transport (video countdown): a 4-minute cycle ─────────────────
0–60 s "Sermon Bumper.mp4" (3:00) playing
60–75 s the same bumper paused at 1:00
75–90 s playing again
90–120 s nothing loaded
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
must infer looping from the position jumping back.
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
name, artist, audio_only, duration }, `time` = bare seconds (float). */
function transport() {
const phase = ((Date.now() - startedAt) / 1000) % 240;
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
if (phase < 90) {
const playing = phase < 60 || phase >= 75;
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
return {
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
time: Math.round(pos * 10) / 10,
};
}
if (phase < 120) return none;
return {
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
time: Math.round(((phase - 120) % 15) * 10) / 10,
};
}
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
+203
View File
@@ -0,0 +1,203 @@
'use strict';
/**
* A packaged ProdDash carries no modules. This drives a packaged copy of the
* shell — server.js and its client in a folder with no .git — through the
* three things that make that true:
*
* 1. an install made before 1.10.0 still has modules in its app folder:
* at boot they move into the data directory once, executables keeping
* their mode, an uninstalled one is dropped, and the app folder's
* modules/ is gone;
* 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them.
*
* node tools/unbundled.test.js
*
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
* directory. Exits non-zero on the first failure and cleans up after itself.
*/
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const REPO = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
function writeModule(dir, id, version, extra = {}) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
}
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
function buildArchive(dir) {
const top = path.join(dir, 'ProdDash-main');
fs.mkdirSync(top, { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz');
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
return fs.readFileSync(out);
}
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
function buildPackagedApp(app) {
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
}
(async () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
const app = path.join(work, 'app');
const dataDir = path.join(work, 'data');
fs.mkdirSync(dataDir);
buildPackagedApp(app);
// The operator had uninstalled "gone" before this update.
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
const archive = buildArchive(path.join(work, 'forge'));
const forge = http.createServer((req, res) => {
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
res.writeHead(200, { 'Content-Type': 'application/gzip' });
return res.end(archive);
}
res.writeHead(404);
res.end();
});
const forgePort = await freePort();
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const env = {
...process.env,
PRODDASH_DATA_DIR: dataDir,
PORT: String(port),
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
PRODDASH_REPO: 'michael/ProdDash',
PRODDASH_NO_REMOTE_CHECK: '1',
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
};
let log = '';
let child = null;
const start = () => {
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
return child;
};
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
let failed = false;
try {
// ── 1. the move ──
start();
await waitFor(base + '/api/theme');
const moved = path.join(dataDir, 'modules', 'demo');
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
const helper = path.join(moved, 'bin', 'demo-helper');
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
assert.match(log, /the app folder carries no modules now/);
const state = (await api('/api/admin/state')).body;
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
// The catalog sees the repo's modules/ — as offers, not as a bundle.
let cat = (await api('/api/admin/catalog?refresh=1')).body;
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
// ── 2. a shell update carries no modules ──
const running = child;
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
log = '';
start();
await waitFor(base + '/api/theme');
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
cat = (await api('/api/admin/catalog')).body;
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
// ── 3. several at once ──
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
assert.equal(many.status, 200);
assert.equal(many.body.ok, false, 'one of them does not exist');
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said');
console.log('unbundled shell: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2500));
} finally {
try { child?.kill('SIGTERM'); } catch { /* gone */ }
forge.close();
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(work, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();