Files
ProdDash/modules/propresenter-timers/server.js
Waters ChurchandClaude Fable 5.1 efbcf960dd Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits
Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:53:41 -04:00

1195 lines
46 KiB
JavaScript

'use strict';
/**
* Timers — server part.
*
* One tile for every timer and clock in the room, from any source. Each
* SOURCE turns its own world into the module guide's standard timer objects
* ({ id, label, kind, state, startedAt, elapsedMs, remainingMs, targetMs,
* status, detail } — see "Offering timers to the Timers module") and reports
* its health. This file merges the sources, fans the merged state out to the
* tiles over SSE, and offers the whole set to the rest of ProdDash as a
* timers provider itself. Browsers never talk to ProPresenter, the audio
* hardware or other modules directly.
*
* Sources (one factory each, below):
* propresenter the ProPresenter openAPI timer list + live values, polled
* exactly as before (admin switch: ppEnabled)
* ltc SMPTE LTC decoded from an audio input by the built-in
* listener (ltc-listener.js / ltc.js — untouched), shown as a
* kind:"clock" timer whose status.text is the timecode and
* whose `ltc` extension carries fps/df/ageMs so the tile can
* count frames between reports (admin switch: ltcEnabled)
* clock time of day. Trivial here — the tile paints its own wall
* clock — it exists so the clock is one more entry in
* /timers and in the tile's list, like everything else
* modules every other module whose manifest says
* "provides": ["timers"], discovered from the shell's
* /api/modules over loopback (shell.port, never guessed) and
* read through its /timers/stream when that answers as SSE,
* else its /timers polled every 2 s. Re-discovered every 30 s
* so a newly installed provider appears without a restart.
* PCO Plan sits behind the admin pcoEnabled switch; any other
* provider is consumed as soon as it is found (give it a
* switch of its own in module.json the day one needs it).
*
* ADDING A SOURCE (another timer platform) is a dozen lines. Write a factory
* that returns
*
* { id, label, kind, start(emit), stop(), health() }
*
* id short, stable ('propresenter', 'ltc', …) — the tile keys its
* show/hide settings on `${id}:${timer.id}`
* label the heading the tile puts over this source's timers
* kind which tile switch governs it: 'propresenter' | 'ltc' | 'clock'
* | 'module' (a new platform would add its own switch and kind)
* start(emit) begins following the upstream and calls
* emit({ status, message, timers, updatedAt }) whenever the view
* changes — status 'ok' | 'connecting' | 'error', message one
* human line (shown under the heading while not ok), timers an
* array of standard timer objects as of updatedAt
* stop() tears everything down (a re-init follows every admin save)
* health() → { status, message } for the admin health line
*
* then, in init(), push it onto `fixed` behind its own admin `switch` field
* (named as that group's `toggle` in module.json). Merging, change
* detection, both SSE feeds, /timers and the tile are generic — nothing
* else changes.
*
* Routes:
* GET /state → { state } tile snapshot (instant paint)
* GET /stream → SSE `state` events tile feed, change-detected
* GET /timers → provider snapshot the module guide's contract
* GET /timers/stream → SSE `timers` events same, for other consumers
* GET /ltc/devices → this machine's audio inputs, for the admin page's
* device picker (select + optionsRoute)
*
* ProPresenter endpoints, verified against openapi.propresenter.com (v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
* countdown:{duration} | count_down_to_time:{time_of_day,period}
* | elapsed:{start_time,end_time} } ]
* GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ]
* `time` may be negative ("-00:00:02") while overrunning, and some builds
* append hundredths. `state` enum is stopped|running|complete|overrunning|
* overran — but the spec's own example says "overrun", so anything
* containing "overr" is overrun. The API exposes NO per-timer color.
* ProPresenter's HTTP server is easily overwhelmed, so requests run strictly
* sequentially, live values are the only every-tick call, and the timer
* list rides a slow lane.
*/
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */
const SELF_ID = 'propresenter-timers';
/** The provider the admin "PCO Plan" switch governs; every other provider is always on. */
const PCO_ID = 'pco-plan';
/* ProPresenter cadence. */
const POLL_MS = 500;
const RETRY_MS = 2500;
const SLOW_EVERY = 4;
const REQUEST_TIMEOUT_MS = 4000;
/* Timers offered by other modules. */
const DISCOVER_MS = 30000; // re-read /api/modules: a newly installed provider shows up without a restart
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
const MODULE_POLL_MS = 2000; // GET /timers cadence for a provider with no stream
const BACKOFF_MIN_MS = 3000;
const BACKOFF_MAX_MS = 15000;
const GRACE_MS = 10000; // a provider's timers survive this long without a fresh snapshot
const STREAM_SILENCE_MS = 45000; // a stream with no bytes for this long (heartbeats are 15 s) is dead
const MAX_TIMERS_PER_PROVIDER = 50;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
const KINDS = new Set(['elapsed', 'countdown', 'clock']);
const STATES = new Set(['running', 'paused', 'stopped', 'idle']);
const TONES = new Set(['ok', 'warn', 'danger', 'muted']);
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
let current = null;
/* ── small helpers ────────────────────────────────────────────────────── */
function sendJson(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
/** Open an SSE response, send the first frame, and register it in `set`. */
function openSse(req, res, set, firstFrame) {
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(firstFrame);
if (!set) return void res.end();
// A viewer that stops reading (locked tablet, sleeping laptop) must not
// queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
}
function writeAll(set, payload) {
for (const res of set) {
try {
res.write(payload);
} catch {
set.delete(res);
}
}
}
/** "HH:MM:SS", "-HH:MM:SS", "MM:SS", optional ".hh" → signed milliseconds, or null. */
function hmsToMs(str) {
const m = /^\s*(-)?(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d+))?\s*$/.exec(String(str || ''));
if (!m) return null;
const h = m[2] ? Number(m[2]) : 0;
let ms = (h * 3600 + Number(m[3]) * 60 + Number(m[4])) * 1000;
if (m[5]) ms += Math.round(Number('0.' + m[5]) * 1000);
return m[1] ? -ms : ms;
}
/** ms → "M:SS" / "H:MM:SS" (for detail lines). */
function fmtMs(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (v) => String(v).padStart(2, '0');
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
/** ProPresenter's count_down_to_time target, short: "9:30a", "12:05p", or "21:30" for a 24-hour clock. */
function timeOfDayText(seconds, period) {
const s = Number(seconds);
if (!Number.isFinite(s)) return '';
const h = Math.floor(s / 3600) % 24;
const m = Math.floor((s % 3600) / 60);
const p = String(period || '').toLowerCase();
if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')}${p[0]}`;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
}
/* ── validation of what other modules hand us ─────────────────────────── */
function numOrNull(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** One timer object from a provider, coerced to the contract (or null to drop it). */
function sanitizeTimer(t) {
if (!t || typeof t !== 'object') return null;
const id = t.id === null || t.id === undefined ? '' : String(t.id);
if (!id) return null;
let status = null;
if (t.status && typeof t.status === 'object' && t.status.text !== undefined && t.status.text !== null) {
status = { text: String(t.status.text).slice(0, 200) };
if (TONES.has(t.status.tone)) status.tone = t.status.tone;
}
return {
id,
label: String(t.label || id).slice(0, 120),
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
state: STATES.has(t.state) ? t.state : 'stopped',
startedAt: numOrNull(t.startedAt),
elapsedMs: numOrNull(t.elapsedMs),
remainingMs: numOrNull(t.remainingMs),
targetMs: numOrNull(t.targetMs),
status,
detail: t.detail === null || t.detail === undefined ? '' : String(t.detail).slice(0, 200),
};
}
/** A provider's /timers document → { label, timers, updatedAt }, or null when it isn't one. */
function sanitizeSnapshot(raw) {
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.timers)) return null;
return {
label: raw.label ? String(raw.label).slice(0, 60) : '',
timers: raw.timers.map(sanitizeTimer).filter(Boolean).slice(0, MAX_TIMERS_PER_PROVIDER),
updatedAt: Number(raw.updatedAt) || Date.now(),
};
}
/* ── source: ProPresenter ─────────────────────────────────────────────── */
function createProPresenterSource({ config, log }) {
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
// field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || '');
const configured = Boolean(host && port);
let emit = () => {};
let stopped = false;
let pollTimer = null;
let tick = 0;
let reachable = false;
let lastError = '';
let timers = [];
let updatedAt = 0;
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
let timerConfigs = new Map();
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function requestJson(target) {
const response = await fetch(url(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function describe(entry) {
if (entry.countdown) {
const targetMs = Number(entry.countdown.duration) > 0 ? Number(entry.countdown.duration) * 1000 : null;
return { type: 'countdown', targetMs, detail: targetMs !== null ? `Countdown ${fmtMs(targetMs)}` : 'Countdown' };
}
if (entry.count_down_to_time) {
const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period);
return { type: 'countdown_to_time', targetMs: null, detail: at ? `to ${at}` : 'to a time of day' };
}
if (entry.elapsed) {
const end = Number(entry.elapsed.end_time);
const targetMs = Number.isFinite(end) && end > 0 ? end * 1000 : null;
return { type: 'elapsed', targetMs, detail: targetMs !== null ? `Elapsed · to ${fmtMs(targetMs)}` : 'Elapsed' };
}
return { type: 'unknown', targetMs: null, detail: '' };
}
function applyTimerList(list) {
if (!Array.isArray(list)) return;
const next = new Map();
for (const entry of list) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
next.set(uuid, {
name: String(entry.id.name || 'Timer'),
index: Number(entry.id.index) || 0,
allowsOverrun: Boolean(entry.allows_overrun),
...describe(entry),
});
}
timerConfigs = next;
}
/** One ProPresenter timer (configured entry + live value) → a standard timer object. */
function toTimer(uuid, cfg, live, now) {
const rawState = String(live?.state || '').toLowerCase();
const overrun = rawState.includes('overr');
const value = live ? hmsToMs(live.time) : null;
const type = cfg ? cfg.type : 'unknown';
// A timer reported live but missing from the (stale) config list has an
// unknown type until the slow lane catches up (~2 s); treat it as elapsed
// meanwhile — the whole-second display can't drift wrong in that time.
const kind = type === 'countdown' || type === 'countdown_to_time' ? 'countdown' : 'elapsed';
const targetMs = cfg ? cfg.targetMs : null;
let state;
if (!live) state = 'idle';
else if (rawState === 'running' || overrun) state = 'running';
else if (rawState === 'complete') state = 'stopped';
else {
// ProPresenter's "stopped" covers both a timer that never started
// (still showing its full length) and one paused mid-way.
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;
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
return {
id: uuid,
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
kind,
state,
startedAt,
elapsedMs,
remainingMs,
targetMs,
status: { text, tone },
detail: cfg ? cfg.detail : '',
};
}
/** Merge configured timers (order, type) with live values into one list. */
function mergeTimers(currentTimes, now) {
const live = new Map();
if (Array.isArray(currentTimes)) {
for (const entry of currentTimes) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
live.set(uuid, {
name: String(entry.id.name || ''),
time: typeof entry.time === 'string' ? entry.time : '',
state: typeof entry.state === 'string' ? entry.state : '',
});
}
}
const out = [];
for (const [uuid, cfg] of timerConfigs) {
const now_ = live.get(uuid) || null;
live.delete(uuid);
out.push(toTimer(uuid, cfg, now_, now));
}
// 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));
return out;
}
function status() {
if (!configured) return 'error';
if (reachable) return 'ok';
return lastError ? 'error' : 'connecting';
}
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}…`;
}
function publish() {
if (stopped) return;
emit({ status: status(), message: message(), timers, updatedAt });
}
async function pollTick() {
if (stopped) return;
try {
tick += 1;
// Slow lane first on the very first tick so cards get names/types
// immediately; afterwards every SLOW_EVERY ticks.
if (tick % SLOW_EVERY === 1 || !timerConfigs.size) {
applyTimerList(await requestJson('/v1/timers'));
}
// THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current');
updatedAt = Date.now();
timers = mergeTimers(currentTimes, updatedAt);
reachable = true;
lastError = '';
} catch (err) {
reachable = false;
lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
}
publish();
if (stopped) return;
pollTimer = setTimeout(pollTick, reachable ? POLL_MS : RETRY_MS);
}
return {
id: 'propresenter',
label: 'ProPresenter',
kind: 'propresenter',
start(e) {
emit = e;
if (!configured) {
log('ProPresenter timers on, but no host configured — set one in /admin');
publish();
return;
}
log(`polling timers on ${host}:${port}`);
pollTick();
},
stop() {
stopped = true;
clearTimeout(pollTimer);
},
health() {
return { status: status(), message: message() };
},
};
}
/* ── source: LTC timecode (built-in listener) ─────────────────────────── */
function createLtcSource({ config, log, moduleDir }) {
const device = String(config.ltcDevice || '');
const channel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
let emit = () => {};
let listener = null;
let stopped = false;
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
let word = null;
function rateText() {
return word?.df ? '29.97 DF' : word?.fps ? `${word.fps} fps` : '';
}
/** The LTC timecode as one standard timer object (kind clock, timecode in status.text). */
function timer() {
const base = { id: 'ltc', label: 'Timecode', kind: 'clock', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null };
if (!word || word.error) {
const note = word ? word.error : 'Starting LTC listener…';
return { ...base, state: 'idle', status: { text: '', tone: 'muted' }, detail: note, ltc: { time: '', fps: 0, df: false, status: '', note } };
}
const running = word.state === 'running';
const rate = rateText();
const ltc = { time: word.time || '', fps: word.fps || 0, df: Boolean(word.df), status: word.state, note: '' };
// While running: how old this timecode is, so the tile can pin the
// anchor to its own clock and count frames between reports.
if (running && Number.isFinite(word.ageMs)) ltc.ageMs = Math.max(0, word.ageMs);
return {
...base,
state: running ? 'running' : word.state === 'stopped' ? 'stopped' : 'idle',
status: { text: word.time || '', tone: running ? 'ok' : word.state === 'stopped' ? 'muted' : 'warn' },
detail: running ? (rate ? `Running · ${rate}` : 'Running') : word.state === 'stopped' ? 'Stopped' : 'No signal',
ltc,
};
}
function status() {
if (!word) return 'connecting';
return word.error ? 'error' : 'ok';
}
function message() {
if (!word) return 'Starting LTC listener…';
if (word.error) return word.error;
const rate = rateText();
return word.state === 'running' ? `LTC running${rate ? ` · ${rate}` : ''}` : word.state === 'stopped' ? 'LTC stopped' : 'LTC no signal';
}
function publish() {
if (stopped) return;
emit({ status: status(), message: message(), timers: [timer()], updatedAt: Date.now() });
}
return {
id: 'ltc',
label: 'LTC',
kind: 'ltc',
start(e) {
emit = e;
if (!device) {
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
publish();
return;
}
publish();
listener = createLtcListener({
device,
channel,
moduleDir,
log,
onUpdate(update) {
word = update;
publish();
},
});
},
stop() {
stopped = true;
listener?.stop();
listener = null;
},
health() {
return { status: status(), message: message() };
},
};
}
/* ── source: time of day ──────────────────────────────────────────────── */
function createClockSource() {
return {
id: 'clock',
label: 'Clock',
kind: 'clock',
start(emit) {
// status.text (the ISO time) is stamped when a snapshot is served —
// see stampClock() — so this never has to re-emit.
emit({
status: 'ok',
message: 'ticking',
updatedAt: Date.now(),
timers: [{ id: 'local', label: 'Clock', kind: 'clock', state: 'running', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null, status: { text: '', tone: 'ok' }, detail: '' }],
});
},
stop() {},
health() {
return { status: 'ok', message: '' };
},
};
}
/* ── source: another module's timers (over loopback) ─────────────────── */
function createModuleSource({ moduleId, name, shellPort, log }) {
const base = `/api/modules/${encodeURIComponent(moduleId)}`;
let emit = () => {};
let stopped = false;
let label = String(name || moduleId);
let timers = [];
let updatedAt = 0;
let lastOkAt = 0;
let lastError = '';
let state = 'connecting'; // 'ok' | 'connecting' | 'error'
let via = ''; // 'stream' | 'poll' once we know which the provider offers
let stream = null; // the open http.ClientRequest of /timers/stream
let pollTimer = null;
let pollInflight = false;
let retryTimer = null;
let silenceTimer = null;
let backoff = BACKOFF_MIN_MS;
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}`;
}
function publish() {
if (stopped) return;
emit({ label, status: state, message: message(), timers, updatedAt });
}
function accept(raw) {
const snap = sanitizeSnapshot(raw);
if (!snap) {
fail('malformed /timers snapshot');
return;
}
if (snap.label) label = snap.label;
timers = snap.timers;
updatedAt = snap.updatedAt;
lastOkAt = Date.now();
lastError = '';
state = 'ok';
backoff = BACKOFF_MIN_MS;
publish();
}
/** A provider restarting mid-service drops its stream for a moment; its
timers stay up through the grace period and only then disappear. */
function fail(why) {
lastError = why;
if (Date.now() - lastOkAt > GRACE_MS) {
timers = [];
state = 'error';
} else {
state = 'connecting';
}
publish();
}
function later(fn, ms) {
const t = setTimeout(() => { if (!stopped) fn(); }, ms);
t.unref?.();
return t;
}
function backoffLater(fn) {
clearTimeout(retryTimer);
retryTimer = later(fn, backoff);
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
}
function closeStream() {
clearTimeout(silenceTimer);
silenceTimer = null;
if (stream) {
const s = stream;
stream = null;
try { s.destroy(); } catch { /* already gone */ }
}
}
function connectStream() {
if (stopped || stream) return;
let done = false;
const drop = (why) => {
if (done || stopped) return;
done = true;
closeStream();
fail(why);
backoffLater(connectStream);
};
const req = http.get({ host: '127.0.0.1', port: shellPort, path: `${base}/timers/stream`, headers: { Accept: 'text/event-stream' } }, (res) => {
const type = String(res.headers['content-type'] || '');
if (res.statusCode !== 200 || !/text\/event-stream/i.test(type)) {
// No stream here (404: the provider only serves /timers; 502: it
// failed to start) — poll instead, and try the stream again at the
// next discovery pass.
done = true;
res.resume();
stream = null;
via = 'poll';
poll();
return;
}
via = 'stream';
clearTimeout(pollTimer);
pollTimer = null;
res.setEncoding('utf8');
let buf = '';
const armSilence = () => {
clearTimeout(silenceTimer);
silenceTimer = later(() => drop('stream went silent'), STREAM_SILENCE_MS);
};
armSilence();
res.on('data', (chunk) => {
armSilence();
buf += chunk.replace(/\r\n/g, '\n');
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, at);
buf = buf.slice(at + 2);
let event = 'message';
const data = [];
for (const line of block.split('\n')) {
if (!line || line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') event = value;
else if (field === 'data') data.push(value);
}
if (!data.length || (event !== 'timers' && event !== 'message')) continue;
try {
accept(JSON.parse(data.join('\n')));
} catch { /* not for us */ }
}
if (buf.length > 1e6) buf = ''; // runaway guard: never an SSE frame this size
});
res.on('end', () => drop('stream ended'));
res.on('close', () => drop('stream closed'));
res.on('error', (err) => drop(errorText(err)));
});
stream = req;
req.on('error', (err) => drop(errorText(err)));
}
async function pollOnce() {
pollTimer = null;
if (stopped || pollInflight || via === 'stream') return;
pollInflight = true;
let ok = false;
try {
const res = await fetch(`http://127.0.0.1:${shellPort}${base}/timers`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (via === 'stream' || stopped) return; // the stream came up meanwhile
accept(body);
ok = true;
} catch (err) {
if (!stopped && via !== 'stream') fail(errorText(err));
} finally {
pollInflight = false;
}
if (stopped || via === 'stream') return;
if (ok) {
pollTimer = later(pollOnce, MODULE_POLL_MS);
} else {
pollTimer = later(pollOnce, backoff);
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
}
}
function poll() {
if (stopped || pollTimer || pollInflight) return;
pollTimer = later(pollOnce, 0);
}
return {
id: moduleId,
kind: 'module',
get label() {
return label;
},
start(e) {
emit = e;
publish();
connectStream();
},
stop() {
stopped = true;
closeStream();
clearTimeout(retryTimer);
clearTimeout(pollTimer);
retryTimer = null;
pollTimer = null;
},
/** Called on every discovery pass: a provider that had no stream may have one now. */
retryStream() {
if (!stopped && !stream && via === 'poll') connectStream();
},
health() {
return { status: state, message: message() };
},
};
}
/**
* Finds timer providers — every active module whose manifest says
* "provides": ["timers"] — by reading the shell's own /api/modules over
* loopback, and keeps one module source per provider alive.
*/
function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) {
const sources = new Map(); // module id → source
let timer = null;
let stopped = false;
let listed = null; // providers named by the last successful read, [{ id, name }]
let listError = '';
function wanted(id) {
return id === PCO_ID ? flags.pco : true;
}
async function discover() {
timer = null;
if (stopped) return;
try {
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
const res = await fetch(`http://127.0.0.1:${shellPort}/api/modules`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
const mods = Array.isArray(body?.modules) ? body.modules : [];
listed = mods
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes('timers'))
.map((m) => ({ id: m.id, name: String(m.name || m.id) }));
listError = '';
const keep = new Set();
for (const m of listed) {
if (!wanted(m.id)) continue;
keep.add(m.id);
const have = sources.get(m.id);
if (have) {
have.retryStream();
} else {
const source = createModuleSource({ moduleId: m.id, name: m.name, shellPort, log });
sources.set(m.id, source);
log(`following timers offered by module "${m.id}"`);
onAdd(source);
}
}
// Disabled, uninstalled, or no longer offering timers: the shell is
// authoritative, so its timers go now rather than after the grace.
for (const [id, source] of sources) {
if (keep.has(id)) continue;
source.stop();
sources.delete(id);
log(`module "${id}" no longer offers timers`);
onRemove(id);
}
} catch (err) {
listError = errorText(err);
}
if (!stopped) {
timer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
timer.unref?.();
}
}
return {
start() {
timer = setTimeout(discover, DISCOVER_FIRST_MS);
timer.unref?.();
},
stop() {
stopped = true;
clearTimeout(timer);
timer = null;
for (const source of sources.values()) source.stop();
sources.clear();
},
/** Health lines for the admin page: one for PCO Plan, one per other provider. */
health() {
const parts = [];
let status = 'ok';
const bump = (s) => {
if (s === 'error') status = 'error';
else if (s === 'connecting' && status === 'ok') status = 'connecting';
};
if (!flags.pco) parts.push('PCO Plan: off');
if (listed === null) {
parts.push(listError ? `Modules: waiting for the shell (${listError})` : 'Modules: discovering…');
return { status: 'connecting', parts };
}
if (listError) {
parts.push(`Modules: cannot re-read /api/modules (${listError})`);
bump('error');
}
if (flags.pco) {
const pco = sources.get(PCO_ID);
if (pco) {
const h = pco.health();
parts.push(`PCO Plan: ${h.message}`);
bump(h.status);
} else {
parts.push('PCO Plan: not installed, or not offering timers yet');
}
}
// Every other provider found is listed under its own name.
for (const s of [...sources.values()].filter((x) => x.id !== PCO_ID)) {
const h = s.health();
parts.push(`${s.label}: ${h.message}`);
bump(h.status);
}
return { status, parts };
},
};
}
/* ── what each tile's gear offers ─────────────────────────────────────
module.json's instanceSchema is the full set — what an "All timers" tile
offers, and what the shell falls back to for a tile with no picker entry
(one added before the picker listed timers). A solo tile shows one card,
so its picker entry carries a schema of just what that card can use, and
the shell renders that instead (module guide, "Presenting multiple
tiles"): a Message Timer has no use for the clock's format switches.
Every field is drawn from the manifest so it is defined once; the client
falls back to its own defaults for keys a slimmer schema leaves out. */
const FULL_SCHEMA = manifest.instanceSchema || {};
const FULL_GROUPS = manifest.instanceGroups || {};
/**
* A gear schema of just `keys` (manifest order kept) with the groups they
* use. `loose` names keys shown outside their group; `groups` overrides a
* group's meta. A group toggle whose switch isn't among the keys is dropped.
*/
function gearOf(keys, { loose = [], groups = {} } = {}) {
const instanceSchema = {};
for (const key of Object.keys(FULL_SCHEMA)) {
if (!keys.includes(key)) continue;
const spec = { ...FULL_SCHEMA[key] };
if (loose.includes(key)) delete spec.group;
instanceSchema[key] = spec;
}
const instanceGroups = {};
for (const spec of Object.values(instanceSchema)) {
if (!spec.group || instanceGroups[spec.group]) continue;
const meta = { ...(FULL_GROUPS[spec.group] || {}), ...(groups[spec.group] || {}) };
if (meta.toggle && !(meta.toggle in instanceSchema)) delete meta.toggle;
instanceGroups[spec.group] = meta;
}
return { instanceSchema, instanceGroups };
}
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
/** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = {
/** Every card's elements, the headings, how the clock reads, the tones. */
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 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. */
clock: gearOf(CLOCK_ELEMENTS),
/** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
};
/** The gear a solo card of this timer gets — by what the card can show. */
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;
return GEAR.timer;
}
/* ── the module ───────────────────────────────────────────────────────── */
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
const ORDER = { propresenter: 0, ltc: 1, clock: 2, [PCO_ID]: 3 };
function emptyState(timerFont = 'default') {
return { serverNow: Date.now(), timerFont, sources: [] };
}
function emptyProvider() {
return { source: SELF_ID, label: 'Timers', updatedAt: Date.now(), timers: [] };
}
module.exports = {
init({ config, log, shell }) {
const shellPort = Number(shell && shell.port) || 0;
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
const flags = {
pp: config.ppEnabled !== false,
ltc: Boolean(config.ltcEnabled),
pco: config.pcoEnabled !== false,
};
const tileStreams = new Set();
const providerStreams = new Set();
/** source id → { id, kind, label, status, message, timers, updatedAt } */
const views = new Map();
let latest = emptyState(timerFont);
let lastKey = '';
let stopped = false;
const orderedViews = () => [...views.values()].sort((a, b) => ((ORDER[a.id] ?? 10) - (ORDER[b.id] ?? 10)) || a.label.localeCompare(b.label));
/** The tile state: every source, its health, its timers as of updatedAt. */
function buildState() {
return {
serverNow: Date.now(),
timerFont,
sources: orderedViews().map((v) => ({ id: v.id, label: v.label, kind: v.kind, status: v.status, message: v.message, updatedAt: v.updatedAt, timers: v.timers })),
};
}
/**
* The provider snapshot (the module guide's contract): all enabled
* sources merged, ids prefixed with their source so they stay unique,
* every duration re-anchored to this snapshot's own instant.
*/
function buildProviderSnapshot() {
const now = Date.now();
const timers = [];
for (const v of orderedViews()) {
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);
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 };
}
timers.push(out);
}
}
return { source: SELF_ID, label: 'Timers', updatedAt: now, timers };
}
/** Recompute the merged state; broadcast only when something other than the clocks moved. */
function rebuild() {
if (stopped) return;
latest = buildState();
// updatedAt / serverNow move on every poll, and a startedAt derived
// from a whole-second value jitters with them; the tiles extrapolate
// from the values but don't need a frame for that alone.
let key;
try {
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers.map(({ startedAt, ...rest }) => rest)]));
} catch {
return;
}
if (key === lastKey) return;
lastKey = key;
if (tileStreams.size) writeAll(tileStreams, `event: state\ndata: ${JSON.stringify(latest)}\n\n`);
if (providerStreams.size) writeAll(providerStreams, `event: timers\ndata: ${JSON.stringify(buildProviderSnapshot())}\n\n`);
}
const emitFor = (source) => (view) => {
if (stopped) return;
views.set(source.id, {
id: source.id,
kind: source.kind,
label: String(view.label || source.label),
status: view.status === 'ok' || view.status === 'connecting' || view.status === 'error' ? view.status : 'ok',
message: String(view.message || ''),
timers: Array.isArray(view.timers) ? view.timers : [],
updatedAt: Number(view.updatedAt) || Date.now(),
});
rebuild();
};
// The fixed sources, each behind its admin switch. Add a platform here.
const fixed = [];
if (flags.pp) fixed.push(createProPresenterSource({ config, log }));
else log('ProPresenter timers are off (admin switch)');
if (flags.ltc) fixed.push(createLtcSource({ config, log, moduleDir: __dirname }));
fixed.push(createClockSource());
const discovery = createModuleDiscovery({
shellPort,
log,
flags,
onAdd(source) {
source.start(emitFor(source));
},
onRemove(id) {
views.delete(id);
rebuild();
},
});
for (const source of fixed) {
try {
source.start(emitFor(source));
} catch (err) {
log(`${source.id} failed to start: ${err.message}`);
}
}
discovery.start();
if (!flags.pco) log('PCO Plan timers are off (admin switch)');
const heartbeat = setInterval(() => {
writeAll(tileStreams, ': ping\n\n');
writeAll(providerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = {
tileStreams,
providerStreams,
getState: () => ({ ...latest, serverNow: Date.now() }),
getProviderSnapshot: buildProviderSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(heartbeat);
discovery.stop();
for (const source of fixed) {
try { source.stop(); } catch { /* already down */ }
}
for (const set of [tileStreams, providerStreams]) {
for (const res of set) {
try { res.end(); } catch { /* already gone */ }
}
set.clear();
}
if (current && current.tileStreams === tileStreams) current = null;
},
health() {
const parts = [];
let status = 'ok';
const bump = (s) => {
if (s === 'error') status = 'error';
else if (s === 'connecting' && status === 'ok') status = 'connecting';
};
for (const source of fixed) {
if (source.id === 'clock') continue;
const h = source.health();
parts.push(`${source.label}: ${h.message}`);
bump(h.status);
}
if (!flags.pp) parts.push('ProPresenter: off');
if (!flags.ltc) parts.push('LTC: off');
const d = discovery.health();
parts.push(...d.parts);
bump(d.status);
return { status, message: parts.join(' · ') };
},
};
},
/**
* The Add-tile picker's entries: "All timers" plus one solo card per known
* timer from any source (moduleApi.variant carries the entry id — 'all',
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
* working). Each entry carries the gear settings that fit its tile (see
* GEAR above); descriptions are the picker's tooltips, one sentence each.
* Answered from the merged state, never from upstream — the picker must
* stay instant and a dead upstream must not stall it.
*/
tiles() {
const state = current ? current.getState() : null;
if (!state) return [];
const KIND_WORD = { countdown: 'countdown', elapsed: 'elapsed timer', clock: 'clock' };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
const out = [{ id: 'all', name: 'All timers', description: 'Every timer and clock, grouped by source', ...GEAR.all }];
for (const src of state.sources) {
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`;
// 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 } : {}) });
}
}
return out;
},
routes() {
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.tileStreams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
// ProdDash itself as a timers provider — the module guide's contract.
'GET /timers': (req, res) => {
sendJson(res, 200, current ? current.getProviderSnapshot() : emptyProvider());
},
'GET /timers/stream': (req, res) => {
const snap = current ? current.getProviderSnapshot() : emptyProvider();
openSse(req, res, current ? current.providerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
'GET /ltc/devices': (req, res) => {
const bin = path.join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
},
};
},
};