Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 d24a050193 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:01 -04:00
Waters ChurchandClaude Opus 5 d93162b4c4 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-27 11:32:10 -04:00
Waters ChurchandClaude Opus 5 93e9f714aa 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-27 11:27:31 -04:00
Waters ChurchandClaude Opus 5 c9d7c02bd6 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-27 11:18:25 -04:00
11 changed files with 398 additions and 259 deletions
+13 -2
View File
@@ -323,8 +323,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,
@@ -355,6 +356,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
+9 -5
View File
@@ -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')) {
+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}`;
}
+32 -2
View File
@@ -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
}
}
}
]
}
+94 -7
View File
@@ -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; }
+30 -136
View File
@@ -10,23 +10,13 @@
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
* { error: '<what is wrong and how to fix it>' } not capturing
*
* Error strings are two-tier on purpose: `error` is what a dashboard tile
* shows (short — "Not configured — see Admin"), `detail` is the full story
* for the admin health line and the log.
*
* The capture binary is per-machine (gitignored), and a catalog install or
* update ships source only — the update even wipes a previously built
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
* background as part of coming up: kicked when the LTC source starts and by
* the admin device picker, never on the event loop (a blocking build once
* froze the whole server — see spl-meter's history), built to a temp name
* and renamed so a killed build can't leave a half-written "binary", and
* rebuilt when a module update leaves the source newer than the binary.
* A dead capture child is restarted with backoff — fast for a crash, slow
* for config-shaped failures (unknown device, ambiguity) that never fix
* themselves.
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
*
* macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
@@ -37,7 +27,7 @@
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const { spawn, spawnSync } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000;
@@ -51,100 +41,6 @@ const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/* ── the capture tool: build it when the install didn't bring one ──────── */
/** Tile-facing words (short); the detail goes to admin health and the log. */
const TOOL_BUILDING = 'Capture tool building…';
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
+ 'the module folder) and copy the binary next to ltc-capture.swift';
let building = false;
let buildFailed = false;
/**
* Make sure the capture tool exists, building it in the background when it
* does not — module scope, so the LTC source can kick it the moment it
* starts and the admin device picker can kick it before any device is
* configured (a listener-only build would deadlock: no tool → no device
* list → no device → no listener). A catalog install ships source only and
* a catalog update deletes the module folder (old binary included), so this
* IS the install step for the tool.
*
* Never blocks (a synchronous build once held the whole server's event loop
* — every module — for the duration). Builds to a temp name and renames, so
* a killed or crashed build can't leave a truncated file that looks like a
* ready binary. A binary older than the source (a module update over an
* existing folder) keeps serving while a replacement builds behind it.
*
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
* is usable right now; otherwise `error` is the short tile text and
* `detail` the full one.
*/
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'ltc-capture');
const src = bin + '.swift';
const ok = { ready: true, error: '', detail: '' };
let haveBin = false;
let stale = false;
try {
haveBin = fs.existsSync(bin);
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
} catch { /* treat as missing */ }
if (haveBin && !stale) return ok;
if (!fs.existsSync(src)) {
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
}
const notYet = haveBin ? ok : {
ready: false,
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
};
if (building || buildFailed) return notYet;
building = true;
const tmp = bin + '.building';
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
: 'ltc-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
giveUp.unref?.();
build.on('error', (err) => {
clearTimeout(giveUp);
building = false;
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(tmp)) {
try {
fs.renameSync(tmp, bin);
} catch (err) {
buildFailed = true;
log(`ltc-capture built but could not be moved into place: ${err.message}`);
return;
}
buildFailed = false;
log('ltc-capture built');
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return notYet;
}
/**
* @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts
@@ -179,21 +75,26 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
return 'nosignal';
}
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
log('ltc-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('ltc-capture compiled');
return '';
}
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
+ 'and copy it here';
}
function start() {
if (stopped) return;
const tool = ensureCaptureBinary({
moduleDir,
log,
onReady() {
// The build this start() was waiting on landed — go now.
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
},
});
if (!tool.ready) {
report({ error: tool.error, detail: tool.detail });
// A build in flight restarts us itself when it lands; anything else is
// config-shaped and should back off hard rather than spin.
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
const missing = ensureBinary();
if (missing) {
report({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -212,10 +113,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const channels = Number(m[2]);
const rate = Number(m[3]);
if (channel > channels) {
report({
error: 'Channel not on this device — see Admin',
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
});
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
try { child.kill(); } catch { /* exiting anyway */ }
return;
}
@@ -237,21 +135,17 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
});
child.on('error', (err) => {
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
report({ error: `LTC capture failed to start: ${err.message}` });
});
child.on('exit', (code) => {
child = null;
if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful DETAIL we have; the tile just
// needs to know the input is down and where to fix it.
// permission hint) is the most useful thing we can show.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000;
report({
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
});
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
});
}
@@ -294,4 +188,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
};
}
module.exports = { createLtcListener, ensureCaptureBinary };
module.exports = { createLtcListener };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.5.0",
"version": "1.4.2",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": [
+11 -47
View File
@@ -81,7 +81,7 @@
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
const { createLtcListener } = require('./ltc-listener');
const { createTimerPhase } = require('./phase');
const manifest = require('./module.json');
@@ -396,16 +396,7 @@ function createProPresenterSource({ config, log }) {
return lastError ? 'error' : 'connecting';
}
/** Short, for the tiles — a dashboard warns, the admin page explains.
The client prefixes the source label, so no "ProPresenter" here. */
function tileMessage() {
if (!configured) return 'Not configured — see Admin';
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return 'Unreachable';
}
/** The full story, for the admin health line. */
function adminMessage() {
function message() {
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
@@ -413,7 +404,7 @@ function createProPresenterSource({ config, log }) {
function publish() {
if (stopped) return;
emit({ status: status(), message: tileMessage(), timers, updatedAt });
emit({ status: status(), message: message(), timers, updatedAt });
}
async function pollTick() {
@@ -459,7 +450,7 @@ function createProPresenterSource({ config, log }) {
clearTimeout(pollTimer);
},
health() {
return { status: status(), message: adminMessage() };
return { status: status(), message: message() };
},
};
}
@@ -473,9 +464,7 @@ function createLtcSource({ config, log, moduleDir }) {
let emit = () => {};
let listener = null;
let stopped = false;
/** The listener's latest word: { state, time, fps, df, ageMs } |
{ error, detail } (error = short tile text, detail = the admin/log
story) | null while starting. */
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
let word = null;
function rateText() {
@@ -527,16 +516,8 @@ function createLtcSource({ config, log, moduleDir }) {
kind: 'ltc',
start(e) {
emit = e;
// Kick the capture-tool build right away: a catalog install ships
// source only and a catalog update wipes any previously built binary,
// so coming up after an install/update must rebuild it without
// waiting for a device to be picked or a listener to need it.
ensureCaptureBinary({ moduleDir, log });
if (!device) {
word = {
error: 'Not configured — see Admin',
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
};
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
publish();
return;
}
@@ -558,8 +539,7 @@ function createLtcSource({ config, log, moduleDir }) {
listener = null;
},
health() {
// The admin page gets the full story; the tiles got the short one.
return { status: status(), message: (word && word.error && word.detail) || message() };
return { status: status(), message: message() };
},
};
}
@@ -608,15 +588,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
let silenceTimer = null;
let backoff = BACKOFF_MIN_MS;
/** Short, for the tiles; the lastError detail belongs to the admin line.
The client prefixes the source label, so it isn't repeated here. */
function tileMessage() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
if (state === 'connecting') return 'Connecting…';
return 'Unavailable';
}
function adminMessage() {
function message() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
return `unreachable — ${lastError}`;
@@ -624,7 +596,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
function publish() {
if (stopped) return;
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
emit({ label, status: state, message: message(), timers, updatedAt });
}
function accept(raw) {
@@ -802,7 +774,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
if (!stopped && !stream && via === 'poll') connectStream();
},
health() {
return { status: state, message: adminMessage() };
return { status: state, message: message() };
},
};
}
@@ -1200,7 +1172,7 @@ module.exports = {
return out;
},
routes({ log }) {
routes() {
return {
'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
@@ -1223,15 +1195,7 @@ module.exports = {
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
// Opening the picker also kicks the capture-tool build when a catalog
// install/update left only the source — without this, no tool meant
// no device list, and no device meant nothing would ever build it.
'GET /ltc/devices': (req, res) => {
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
if (!tool.ready) {
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
return;
}
const bin = path.join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.9.2",
"version": "1.9.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+1 -58
View File
@@ -373,18 +373,7 @@ function mountModule(id) {
const log = makeLog(id);
try {
const serverPath = path.join(man.dir, man.server);
// Drop the module's code from the require cache so every (re)mount runs
// the files on disk. Without this a module UPDATE swaps the folder while
// the process keeps executing the old code until ProdDash itself
// restarts — the manifest (read fresh from disk) then reports a version
// the running code isn't, and anything new in the update silently
// doesn't happen. Modules already have to come up clean on re-init
// (module guide), so a fresh load on config remounts is the same
// contract without the trap.
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
for (const key of Object.keys(require.cache)) {
if (key.startsWith(cachePrefix)) delete require.cache[key];
}
// Re-require fresh config on every (re)mount; the module code itself stays cached.
const mod = require(serverPath);
const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
@@ -1071,22 +1060,13 @@ async function installModule(id) {
if (download) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set();
for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
shipped.add(rel);
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in
// by hand on a machine whose toolchain can't build it — are gitignored,
// so the repo never ships them. Replacing the folder must not destroy
// them: carry executables the download doesn't include over from the
// copy being replaced (or from the app's bundled copy on a first
// install), or "update" quietly kills a working module.
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
unmountModule(id);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
@@ -1103,43 +1083,6 @@ async function installModule(id) {
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
}
/**
* Copy executable files (any execute bit) that `shipped` does not include
* from the first existing source dir into `staging`, keeping their relative
* path and mode. Executables only, on purpose: they are the per-machine
* build artifacts (gitignored, so never in a download), while stray text
* files from an old version — code an update deliberately removed — must
* NOT be resurrected.
*/
function carryOverExecutables(sourceDirs, staging, shipped) {
const from = sourceDirs.find((d) => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
if (!from) return;
const walk = (rel) => {
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(relPath);
continue;
}
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
try {
const stat = fs.statSync(path.join(from, relPath));
if (!(stat.mode & 0o111)) continue;
const target = path.join(staging, relPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(from, relPath), target);
fs.chmodSync(target, stat.mode);
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
} catch (err) {
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
}
}
};
walk('');
}
/** Remove a module: an installed copy is deleted; a bundled one is switched
off and hidden (its folder belongs to the app). Settings are kept so a
later reinstall picks them up again. */