Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37575f662 | ||
|
|
07e4c78417 | ||
|
|
9ca301b4be | ||
|
|
ece2c0a2c3 | ||
|
|
32180bf299 | ||
|
|
06066529bc | ||
|
|
4e5b60e82b | ||
|
|
92b98b6579 | ||
|
|
58f3e5010a | ||
|
|
7cc19ef696 | ||
|
|
e9d6edc2c8 | ||
|
|
9313c9e42a |
@@ -28,7 +28,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).
|
||||
|
||||
@@ -325,8 +325,9 @@ below. Nothing is installed without someone clicking.
|
||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
||||
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
||||
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
||||
Key** (the key of the item on screen).
|
||||
|
||||
**Plan** shows every
|
||||
plan item with its scheduled start time, length, type, song key,
|
||||
@@ -357,6 +358,16 @@ below. Nothing is installed without someone clicking.
|
||||
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
|
||||
|
||||
+68
-1
@@ -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,6 +86,10 @@ 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>/
|
||||
```
|
||||
|
||||
@@ -97,6 +104,66 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* PCO Plan — client part.
|
||||
|
||||
The module presents two tiles and this is the entry for both: the Teams
|
||||
tile (picker entry "teams") is built by teams-client.js, everything below
|
||||
draws the Plan tile.
|
||||
The module presents three tiles and this is the entry for all of them: the
|
||||
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
||||
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
||||
|
||||
The server part reads Planning Center and (optionally) follows the
|
||||
ProPresenter module's live item; this tile only renders the plan it is
|
||||
@@ -13,6 +13,8 @@
|
||||
the connection settings. */
|
||||
|
||||
import createTeams from "./teams-client.js";
|
||||
import createKey from "./key-client.js";
|
||||
import { prettyKey } from "./key-notation.js";
|
||||
|
||||
const REFRESH_SVG = '<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>';
|
||||
|
||||
@@ -21,8 +23,9 @@ 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 }) {
|
||||
// Tiles added as "Teams" carry that variant and have their own settings.
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -367,7 +370,8 @@ function createPlan({ 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')) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* PCO Plan — the Song Key tile.
|
||||
|
||||
One thing, big: the key of the plan item on screen. It reads the same
|
||||
streamed state the Plan tile does, so it needs no connection of its own;
|
||||
which item is current comes from the ProPresenter module, as ever. */
|
||||
|
||||
import { parseKey, prettyKey } from "./key-notation.js";
|
||||
|
||||
export default function createKey({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let feedOffline = false;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="pk-wrap">
|
||||
<div class="pk-key"></div>
|
||||
<div class="pk-name"></div>
|
||||
<div class="pk-next"></div>
|
||||
</div>`;
|
||||
|
||||
const wrap = root.querySelector('.pk-wrap');
|
||||
const keyEl = root.querySelector('.pk-key');
|
||||
const nameEl = root.querySelector('.pk-name');
|
||||
const nextEl = root.querySelector('.pk-next');
|
||||
|
||||
const cfg = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||
|
||||
/* ── the item on screen, and the one after it ───────────────────── */
|
||||
|
||||
function items() {
|
||||
return Array.isArray(state?.items) ? state.items : [];
|
||||
}
|
||||
|
||||
function currentItem() {
|
||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
||||
return id ? items().find((it) => it.id === id) || null : null;
|
||||
}
|
||||
|
||||
/** The next item that actually has a key — a talk between two songs is skipped. */
|
||||
function nextKeyed(after) {
|
||||
const list = items();
|
||||
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
|
||||
if (from <= 0) return null;
|
||||
for (let i = from; i < list.length; i += 1) {
|
||||
if (list[i].keyName) return list[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function titleOf(item) {
|
||||
return item ? (item.song?.title || item.title || '') : '';
|
||||
}
|
||||
|
||||
/* ── render ─────────────────────────────────────────────────────── */
|
||||
|
||||
function renderStatus() {
|
||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
||||
if (!state.reachable) {
|
||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||
}
|
||||
const item = currentItem();
|
||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
||||
// The key on screen stays — the band still needs it — but a tile must
|
||||
// never look confidently live while the feed that chose it is gone.
|
||||
if (!state.live.available) {
|
||||
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
|
||||
}
|
||||
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
|
||||
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
|
||||
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
|
||||
* better with the accidental raised and the minor kept small.
|
||||
*/
|
||||
function drawKey(text) {
|
||||
keyEl.innerHTML = '';
|
||||
const parts = parseKey(text);
|
||||
if (!parts) {
|
||||
keyEl.textContent = text;
|
||||
return;
|
||||
}
|
||||
keyEl.appendChild(document.createTextNode(parts.note));
|
||||
if (parts.accidental) {
|
||||
const acc = document.createElement('span');
|
||||
acc.className = 'pk-accidental';
|
||||
acc.textContent = parts.accidental;
|
||||
keyEl.appendChild(acc);
|
||||
}
|
||||
if (parts.quality) {
|
||||
const tail = document.createElement('span');
|
||||
tail.className = 'pk-quality';
|
||||
tail.textContent = parts.quality;
|
||||
keyEl.appendChild(tail);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
const live = state?.live;
|
||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
||||
const color = String(cfg().keyColor || '').trim();
|
||||
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
|
||||
renderStatus();
|
||||
|
||||
const item = currentItem();
|
||||
const key = item?.keyName || '';
|
||||
wrap.classList.toggle('has-key', Boolean(key));
|
||||
|
||||
if (!state || !state.configured || !state.serviceTypeSet) {
|
||||
drawKey('—');
|
||||
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
|
||||
nextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!item) {
|
||||
drawKey('—');
|
||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
||||
nextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
drawKey(key || '—');
|
||||
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
|
||||
nameEl.hidden = !nameEl.textContent;
|
||||
|
||||
const next = on('showNextKey') ? nextKeyed(item) : null;
|
||||
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
|
||||
nextEl.hidden = !nextEl.textContent;
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
function accept(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() { feedOffline = false; render(); },
|
||||
error() { feedOffline = true; render(); },
|
||||
events: {
|
||||
state(ev) {
|
||||
try {
|
||||
accept(JSON.parse(ev.data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
moduleApi.fetch('/state')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onConfigChange() {
|
||||
// Admin holds the connection only; the server re-inits and re-sends.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* PCO Plan — how a song key is written.
|
||||
|
||||
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
|
||||
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
|
||||
on an item and the Song Key tile all agree. Shared rather than repeated
|
||||
because the day one of them changes, they all should. */
|
||||
|
||||
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
|
||||
export function parseKey(raw) {
|
||||
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
|
||||
if (!match) return null;
|
||||
const [, note, accidental, quality] = match;
|
||||
return {
|
||||
note,
|
||||
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
|
||||
quality: quality.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The same thing as one string, for a badge or a caption. */
|
||||
export function prettyKey(raw) {
|
||||
const parts = parseKey(raw);
|
||||
if (!parts) return String(raw || '').trim();
|
||||
return `${parts.note}${parts.accidental}${parts.quality}`;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service and team assignments from Planning Center Services",
|
||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
@@ -621,6 +621,36 @@
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "key",
|
||||
"name": "Song Key",
|
||||
"description": "The key of the item on screen, big",
|
||||
"defaultSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"minSize": {
|
||||
"w": 1,
|
||||
"h": 1
|
||||
},
|
||||
"instanceSchema": {
|
||||
"keyColor": {
|
||||
"type": "color",
|
||||
"label": "Key",
|
||||
"default": "#4ea1ff"
|
||||
},
|
||||
"showSongName": {
|
||||
"type": "boolean",
|
||||
"label": "Song name",
|
||||
"default": true
|
||||
},
|
||||
"showNextKey": {
|
||||
"type": "boolean",
|
||||
"label": "Next key",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -206,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);
|
||||
@@ -635,25 +637,110 @@
|
||||
|
||||
[data-module="pco-plan"] .pp-abs-sec {
|
||||
flex: none;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
/* 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;
|
||||
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) 40%, transparent) var(--pp-sec-fill, 0%),
|
||||
transparent var(--pp-sec-fill, 0%));
|
||||
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
||||
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
|
||||
}
|
||||
|
||||
/* ── the Song Key tile ────────────────────────────────────────────────
|
||||
One value, as large as the tile allows. Sizes come from the tile itself
|
||||
(container units), like the AbleSet module's single readouts, so a small
|
||||
tile and a wall-sized one both read properly. */
|
||||
|
||||
[data-module="pco-plan"] .pk-wrap {
|
||||
--pk-color: #4ea1ff;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2em;
|
||||
padding: 4%;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pk-key {
|
||||
font-size: clamp(18px, 46cqmin, 260px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
|
||||
|
||||
/* A key is a note, but it has to read as one word at a glance from the back
|
||||
of a room: the accidental and the quality sit on the same line as the
|
||||
letter, only smaller. Raising the accidental looked like two stacked
|
||||
characters at tile sizes. */
|
||||
[data-module="pco-plan"] .pk-accidental {
|
||||
font-size: 0.68em;
|
||||
margin-left: 0.01em;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-quality {
|
||||
font-size: 0.5em;
|
||||
font-weight: 700;
|
||||
margin-left: 0.05em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pk-name {
|
||||
max-width: 100%;
|
||||
font-size: clamp(9px, 9cqmin, 30px);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pk-next {
|
||||
max-width: 100%;
|
||||
font-size: clamp(8px, 6.5cqmin, 20px);
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
|
||||
/* The feed that picks the item is gone: the key stays readable, but faded,
|
||||
so nobody trusts it as current without looking at the status dot. */
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
@@ -114,11 +114,19 @@ module.exports = {
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
// Before anything else: the device picker shells out to this tool, so a
|
||||
// machine that installed the module from the catalog (source only — the
|
||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
||||
// listener would never happen: no tool means no device list, and no
|
||||
// device means no listener.
|
||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
@@ -177,6 +185,9 @@ module.exports = {
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||
return { status: 'error', message: state.toolMissing };
|
||||
}
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const quiet = silentMs();
|
||||
@@ -300,6 +311,13 @@ module.exports = {
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (missing) {
|
||||
if (state) state.toolMissing = missing;
|
||||
log(`device list unavailable — ${missing}`);
|
||||
json(res, 200, { options: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
@@ -438,6 +456,10 @@ function listDevices() {
|
||||
});
|
||||
}
|
||||
|
||||
function fsExists(p) {
|
||||
try { return require('fs').existsSync(p); } catch { return false; }
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||
* the app, or copied from any Mac of the same architecture. When it is
|
||||
* missing this will build it, but in the background and never on the event
|
||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||
* loop, and from module scope so the device picker can trigger it too — see
|
||||
* ensureCaptureBinary for what both of those cost the first time round.
|
||||
*
|
||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||
@@ -30,6 +31,74 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||
* everything that needs it.
|
||||
*
|
||||
* This lives at module scope rather than inside a listener because of a trap
|
||||
* the first version walked into: the admin page's device picker runs the tool
|
||||
* with --list, but the build only ran when a listener started, and a listener
|
||||
* only starts once a device has been picked. On a machine that got the module
|
||||
* from the catalog — source only, the binary being gitignored — the picker
|
||||
* was empty, and nothing would ever fill it.
|
||||
*
|
||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
||||
* listener where the source is a third the size and builds in seconds. Here
|
||||
* it took nearly two minutes on the production Mac and held the Node event
|
||||
* loop for all of it, freezing every other module, then did it again on the
|
||||
* next retry.
|
||||
*
|
||||
* The binary should normally already be there: built beside the app, or
|
||||
* copied from any Mac of the same architecture. This is the fallback.
|
||||
*
|
||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
||||
*/
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return BUILDING;
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})`
|
||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return BUILDING;
|
||||
}
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
@@ -53,64 +122,13 @@ function createSplListener(opts) {
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
/** '' when the tool is ready, otherwise what to say about it. */
|
||||
function ensureBinary() {
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
} });
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
@@ -135,9 +153,13 @@ function createSplListener(opts) {
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
||||
// module: "building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,4 +258,4 @@ function createSplListener(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener };
|
||||
module.exports = { createSplListener, ensureCaptureBinary };
|
||||
|
||||
Reference in New Issue
Block a user