The LTC card repainted only on server anchors (~4x/s), so the frame counter jumped in ~7-frame steps. Now the client counts every frame locally between anchors and re-syncs on each one — frame-accurate and smooth, with no extra network traffic. - Server anchors carry ageMs (how old the decoded timecode is) so clients can pin the anchor to their own clock. ltc-listener reports it while running; composeLtc passes it through (listener) or derives it (reader). - Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/ framesToTc, incl. 29.97 drop-frame minute counting) and advances the display in a requestAnimationFrame loop at the LTC's own rate. - Robust when rAF is throttled (backgrounded tab): render() paints the extrapolated frame at the 4x/s anchor rate too, so the display never freezes — the rAF loop only refines it to every frame when visible. - Anchor jitter can't tick the counter backwards (small regressions clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel into phantom frames when anchors stop arriving. Verified: SMPTE math against the encoder over 200k drop-frame frames plus round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing strict +1-per-frame advance and correct DF boundary crossing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
622 lines
25 KiB
JavaScript
622 lines
25 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* ProPresenter Timers — server part.
|
||
*
|
||
* Polls the ProPresenter openAPI for the configured timer list, the live
|
||
* timer values, and (when the connected build exposes it) LTC timecode, and
|
||
* fans one merged state object out to every tile over SSE. Browsers never
|
||
* talk to ProPresenter directly.
|
||
*
|
||
* GET /state → { state: <state> } (instant paint for new tiles)
|
||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||
* POST /ltc → remote LTC ingest: tools/ltc-reader.js on another
|
||
* machine reports { state, time, fps, df } here.
|
||
* GET /ltc/devices → this machine's audio input devices, for the admin
|
||
* page's device picker (select + optionsRoute).
|
||
*
|
||
* Endpoints used, verified against openapi.propresenter.com (API 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 ("00:00:01.00" appears in the spec's schema example).
|
||
* `state` enum is stopped|running|complete|overrunning|overran — but the
|
||
* spec's own response example says "overrun", so treat anything containing
|
||
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
||
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
|
||
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
|
||
* allowlist either) documents no timecode/LTC route at all — verified
|
||
* live: ProPresenter 21.4 returns 404. We probe this candidate route
|
||
* once per connection anyway so a future ProPresenter that adds it
|
||
* lights up without a module update.
|
||
*
|
||
* Because the HTTP API has no timecode, LTC comes from the audio signal
|
||
* itself. The whole LTC feature sits behind the admin "LTC listener"
|
||
* switch — off means no LTC tile in the picker, no probes, and the ingest
|
||
* route answers 409. When on, the sources in precedence order:
|
||
* 1. the built-in listener (ltc-listener.js — this machine captures the
|
||
* admin-selected device/channel and decodes in-process) while it has
|
||
* actual data;
|
||
* 2. a fresh remote reader report (POST /ltc, tools/ltc-reader.js on the
|
||
* machine that can hear the LTC when this one can't);
|
||
* 3. the listener's own "no signal" verdict (healthy capture, silent
|
||
* feed — believed over the indirect sources below);
|
||
* 4. the HTTP timecode route if some future ProPresenter answers it;
|
||
* 5. a stage-display field labeled "LTC" (see stage-ws.js).
|
||
*
|
||
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||
* strictly sequentially, the next tick is scheduled only after the previous
|
||
* one finished, live values are the only every-tick call, and the timer list
|
||
* rides a slow lane. The endpoints do offer `?chunked` streaming, but polling
|
||
* matches how propresenter-core survives real-world ProPresenter quirks.
|
||
*/
|
||
|
||
/** Hot-path cadence — live timer values. */
|
||
const POLL_MS = 500;
|
||
/** Backoff while ProPresenter is unreachable. */
|
||
const RETRY_MS = 2500;
|
||
/** The configured-timer list refreshes every Nth tick (~2s at 500ms). */
|
||
const SLOW_EVERY = 4;
|
||
/** Abort a hung request well before it can stall the loop for good. */
|
||
const REQUEST_TIMEOUT_MS = 4000;
|
||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||
const HEARTBEAT_MS = 15000;
|
||
/** A reader report older than this no longer speaks for the LTC feed —
|
||
the reader posts at least 1×/s, so 6s of silence means it died. */
|
||
const INGEST_STALE_MS = 6000;
|
||
|
||
const { spawn } = require('child_process');
|
||
const { createStageLtcClient } = require('./stage-ws');
|
||
const { createLtcListener } = require('./ltc-listener');
|
||
|
||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||
let current = null;
|
||
|
||
function clearedState(enabled, ltcEnabled = false) {
|
||
return {
|
||
enabled,
|
||
ltcEnabled,
|
||
reachable: false,
|
||
lastError: '',
|
||
timers: [],
|
||
// supported: null = no verdict yet (probing, or ProPresenter
|
||
// unreachable); true once a timecode source is live; false when every
|
||
// source came up empty (note says why, when we know). status is the
|
||
// richer verdict (running|stopped|nosignal); fps/df only when the
|
||
// source knows them (the reader decodes both from the LTC bits).
|
||
ltc: { supported: null, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
init({ config, log }) {
|
||
const streams = new Set();
|
||
// 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 enabled = Boolean(host && port);
|
||
const ltcEnabled = Boolean(config.ltcEnabled);
|
||
const ltcDevice = String(config.ltcDevice || '');
|
||
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
|
||
|
||
let latest = clearedState(enabled, ltcEnabled);
|
||
let lastFrame = '';
|
||
let stopped = false;
|
||
let pollTimer = null;
|
||
let tick = 0;
|
||
|
||
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
|
||
let timerConfigs = new Map();
|
||
/** null = probe /v1/timecode/status on the next reachable tick. */
|
||
let ltcSupported = null;
|
||
let ltcLast = { time: '', receiving: false };
|
||
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
|
||
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
|
||
/** Latest POST /ltc report from a remote reader, null until one lands. */
|
||
let ingest = null; // { state, time, fps, df, source, at }
|
||
let ingestExpiry = null;
|
||
/** Built-in listener's latest word: { state, time, fps, df } | { error }. */
|
||
let listenerLtc = null;
|
||
|
||
/** One LTC verdict from every source, in the precedence the header
|
||
comment explains. Text sources don't know fps — receiving/frozen/
|
||
empty maps onto the same status vocabulary the audio decode uses. */
|
||
function composeLtc() {
|
||
if (!ltcEnabled) {
|
||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
|
||
}
|
||
const audioVerdict = (src, tag, ageMs) => ({
|
||
supported: true,
|
||
source: tag,
|
||
time: src.time,
|
||
receiving: src.state === 'running',
|
||
status: src.state,
|
||
fps: src.fps,
|
||
df: src.df,
|
||
// While running: how old this timecode is, so clients can pin the
|
||
// anchor to their own clock and count frames between updates.
|
||
...(src.state === 'running' && Number.isFinite(ageMs) ? { ageMs: Math.max(0, ageMs) } : {}),
|
||
note: '',
|
||
});
|
||
// The built-in listener while it has actual data …
|
||
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
|
||
return audioVerdict(listenerLtc, 'listener', listenerLtc.ageMs);
|
||
}
|
||
// … else a fresh remote reader (during migrations both may run —
|
||
// real data beats no data) …
|
||
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
|
||
return audioVerdict(ingest, 'reader', Date.now() - ingest.at);
|
||
}
|
||
// … else a healthy-but-silent listener is believed over the indirect
|
||
// sources: no audio means the LTC really stopped.
|
||
if (listenerLtc && !listenerLtc.error) {
|
||
return audioVerdict(listenerLtc, 'listener');
|
||
}
|
||
const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal');
|
||
if (ltcSupported === true) {
|
||
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, status: textStatus(ltcLast), fps: 0, df: false, note: '' };
|
||
}
|
||
if (stageLtc.bound) {
|
||
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, status: textStatus(stageLtc), fps: 0, df: false, note: '' };
|
||
}
|
||
if (ingest) {
|
||
// A reader reported once but went silent — likelier to be the
|
||
// story than anything the stage feed has to say.
|
||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC reader offline' };
|
||
}
|
||
if (!listenerLtc?.error && ltcSupported === null && !stageLtc.note && enabled) {
|
||
return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' };
|
||
}
|
||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc?.error || stageLtc.note || '' };
|
||
}
|
||
|
||
/** Timecode-shaped text — same shape rule as the other sources. */
|
||
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||
const INGEST_STATES = new Set(['running', 'stopped', 'nosignal']);
|
||
|
||
/** Validated apply of one POST /ltc body. Returns '' or a client error. */
|
||
function applyIngest(body) {
|
||
if (!body || typeof body !== 'object') return 'body must be a JSON object';
|
||
const state = INGEST_STATES.has(body.state) ? body.state : '';
|
||
if (!state) return 'state must be one of running|stopped|nosignal';
|
||
const time = typeof body.time === 'string' && TC_SHAPE.test(body.time.trim()) ? body.time.trim() : '';
|
||
if (state === 'running' && !time) return 'a running report needs a timecode-shaped time';
|
||
ingest = {
|
||
state,
|
||
time,
|
||
fps: Number.isFinite(body.fps) && body.fps > 0 && body.fps <= 120 ? Number(body.fps) : 0,
|
||
df: body.df === true,
|
||
source: String(body.source || '').slice(0, 60),
|
||
at: Date.now(),
|
||
};
|
||
// When the reader dies mid-run the card must not stay green forever —
|
||
// re-broadcast once this report goes stale so the fallbacks take over.
|
||
clearTimeout(ingestExpiry);
|
||
ingestExpiry = setTimeout(broadcast, INGEST_STALE_MS + 100);
|
||
ingestExpiry.unref?.();
|
||
broadcast();
|
||
return '';
|
||
}
|
||
|
||
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 timerType(entry) {
|
||
if (entry.countdown) return 'countdown';
|
||
if (entry.count_down_to_time) return 'countdown_to_time';
|
||
if (entry.elapsed) return 'elapsed';
|
||
return 'unknown';
|
||
}
|
||
|
||
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,
|
||
type: timerType(entry),
|
||
allowsOverrun: Boolean(entry.allows_overrun),
|
||
});
|
||
}
|
||
timerConfigs = next;
|
||
}
|
||
|
||
/** Merge configured timers (order, type) with live values into one list. */
|
||
function mergeTimers(currentTimes) {
|
||
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);
|
||
live.delete(uuid);
|
||
out.push({
|
||
uuid,
|
||
name: (now && now.name) || cfg.name,
|
||
type: cfg.type,
|
||
allowsOverrun: cfg.allowsOverrun,
|
||
time: now ? now.time : '',
|
||
state: now ? now.state : '',
|
||
});
|
||
}
|
||
// 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({ uuid, name: now.name || 'Timer', type: 'unknown', allowsOverrun: false, time: now.time, state: now.state });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Liberal parse of a hypothetical timecode-status body (no spec documents
|
||
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
|
||
* string and a receiving/running boolean under likely key names.
|
||
*/
|
||
function parseLtc(body) {
|
||
const out = { time: '', receiving: false };
|
||
if (!body || typeof body !== 'object') return out;
|
||
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||
for (const key of ['time', 'timecode', 'current_time', 'value']) {
|
||
if (typeof body[key] === 'string' && TC.test(body[key])) {
|
||
out.time = body[key];
|
||
break;
|
||
}
|
||
}
|
||
if (!out.time) {
|
||
for (const value of Object.values(body)) {
|
||
if (typeof value === 'string' && TC.test(value)) {
|
||
out.time = value;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
for (const key of ['receiving', 'running', 'is_running', 'active']) {
|
||
if (typeof body[key] === 'boolean') {
|
||
out.receiving = body[key];
|
||
break;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function pollLtc() {
|
||
if (!ltcEnabled) return;
|
||
// A 404 verdict is retried once a minute — an endpoint appearing
|
||
// without an unreachable spell (ProPresenter hot-swapped in testing,
|
||
// or some future in-place upgrade) must not stay invisible forever.
|
||
if (ltcSupported === false && tick % 120 !== 0) return;
|
||
try {
|
||
const body = await requestJson('/v1/timecode/status');
|
||
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
|
||
ltcSupported = true;
|
||
ltcLast = parseLtc(body);
|
||
} catch (err) {
|
||
// 404 = this ProPresenter has no timecode API (the expected case, as
|
||
// of API v1). Anything else is a real failure — let the tick handle it.
|
||
if (err && err.status === 404) {
|
||
ltcSupported = false;
|
||
ltcLast = { time: '', receiving: false };
|
||
return;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function broadcast() {
|
||
latest = {
|
||
enabled,
|
||
ltcEnabled,
|
||
reachable: latest.reachable,
|
||
lastError: latest.lastError,
|
||
timers: latest.timers,
|
||
ltc: composeLtc(),
|
||
};
|
||
if (!streams.size) return;
|
||
let frame;
|
||
try {
|
||
frame = JSON.stringify(latest);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||
for (const res of streams) {
|
||
try {
|
||
res.write(payload);
|
||
} catch {
|
||
streams.delete(res);
|
||
}
|
||
}
|
||
}
|
||
|
||
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');
|
||
// Probe timecode once per connection; poll it only when it exists.
|
||
await pollLtc();
|
||
latest.timers = mergeTimers(currentTimes);
|
||
latest.reachable = true;
|
||
latest.lastError = '';
|
||
} catch (err) {
|
||
latest.reachable = false;
|
||
latest.lastError = err instanceof Error ? err.message : String(err);
|
||
// Re-probe timecode after a reconnect — the unreachable spell may
|
||
// have been a ProPresenter upgrade or a different machine.
|
||
ltcSupported = null;
|
||
ltcLast = { time: '', receiving: false };
|
||
}
|
||
broadcast();
|
||
if (stopped) return;
|
||
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
|
||
}
|
||
|
||
let stageClient = null;
|
||
if (enabled) {
|
||
pollTick();
|
||
log(`polling timers on ${host}:${port}`);
|
||
if (ltcEnabled) {
|
||
stageClient = createStageLtcClient({
|
||
host,
|
||
port,
|
||
password: String(config.stagePassword || ''),
|
||
log,
|
||
// The HTTP poller knows every configured timer's uuid — anything
|
||
// else streaming timecode-shaped text can be heuristically bound.
|
||
isTimerUid: (uid) => timerConfigs.has(uid),
|
||
onUpdate(update) {
|
||
stageLtc = update;
|
||
broadcast();
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
log('no ProPresenter host configured — set one in /admin');
|
||
}
|
||
|
||
// The built-in listener is independent of ProPresenter — LTC-only
|
||
// setups (no host configured) still get the LTC card.
|
||
let ltcListener = null;
|
||
if (ltcEnabled) {
|
||
if (ltcDevice) {
|
||
ltcListener = createLtcListener({
|
||
device: ltcDevice,
|
||
channel: ltcChannel,
|
||
moduleDir: __dirname,
|
||
log,
|
||
onUpdate(update) {
|
||
listenerLtc = update;
|
||
broadcast();
|
||
},
|
||
});
|
||
} else {
|
||
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
|
||
broadcast();
|
||
}
|
||
}
|
||
|
||
const heartbeat = setInterval(() => {
|
||
for (const res of streams) {
|
||
try {
|
||
res.write(': ping\n\n');
|
||
} catch {
|
||
streams.delete(res);
|
||
}
|
||
}
|
||
}, HEARTBEAT_MS);
|
||
heartbeat.unref?.();
|
||
|
||
current = { streams, getLatest: () => latest, ingest: applyIngest };
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearTimeout(pollTimer);
|
||
clearTimeout(ingestExpiry);
|
||
clearInterval(heartbeat);
|
||
stageClient?.stop();
|
||
ltcListener?.stop();
|
||
for (const res of streams) {
|
||
try { res.end(); } catch { /* already gone */ }
|
||
}
|
||
streams.clear();
|
||
if (current && current.streams === streams) current = null;
|
||
},
|
||
health() {
|
||
const ltcSource = { listener: ', LTC via listener', reader: ', LTC via remote reader', api: ', LTC via API', stage: ', LTC via stage display' };
|
||
if (!enabled) {
|
||
// The LTC listener/reader work regardless of ProPresenter — an
|
||
// LTC-only setup is half-configured but working.
|
||
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only${ltcSource[latest.ltc.source] || ''}` };
|
||
return { status: 'error', message: 'No ProPresenter host configured' };
|
||
}
|
||
if (latest.reachable) {
|
||
const ltc = latest.ltc;
|
||
const ltcNote = !ltcEnabled
|
||
? ''
|
||
: ltc.supported
|
||
? (ltcSource[ltc.source] || '')
|
||
: (ltc.note ? ` (LTC: ${ltc.note})` : '');
|
||
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||
}
|
||
return {
|
||
status: 'error',
|
||
message: latest.lastError
|
||
? `ProPresenter unreachable: ${latest.lastError}`
|
||
: 'Trying to reach ProPresenter…',
|
||
};
|
||
},
|
||
};
|
||
},
|
||
|
||
/**
|
||
* The Add-tile picker's entries: one per discovered timer, plus "All
|
||
* timers" and the LTC card (moduleApi.variant carries the entry id —
|
||
* 'all', 'timer:<uuid>' or 'ltc'). Answered from the poller's latest
|
||
* state, never from a fresh upstream request — the picker must stay
|
||
* instant, and a dead ProPresenter must not stall it.
|
||
*/
|
||
tiles() {
|
||
const state = current ? current.getLatest() : null;
|
||
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
|
||
const TYPE_LABEL = {
|
||
countdown: 'Countdown timer',
|
||
countdown_to_time: 'Countdown to a time of day',
|
||
elapsed: 'Elapsed timer',
|
||
unknown: 'Timer',
|
||
};
|
||
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
||
return [
|
||
{
|
||
id: 'all',
|
||
name: 'All timers',
|
||
description: state.ltcEnabled
|
||
? 'Every timer and the LTC timecode, selectable per tile'
|
||
: 'Every timer, selectable per tile',
|
||
},
|
||
...state.timers.map((t) => ({
|
||
id: `timer:${t.uuid}`,
|
||
name: t.name,
|
||
description: TYPE_LABEL[t.type] || 'Timer',
|
||
...oneCard,
|
||
})),
|
||
// The LTC entry exists only while the admin switch is on.
|
||
...(state.ltcEnabled
|
||
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
|
||
: []),
|
||
];
|
||
},
|
||
|
||
routes({ config }) {
|
||
const sendJson = (res, code, body) => {
|
||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
};
|
||
return {
|
||
'GET /state': (req, res) => {
|
||
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
|
||
},
|
||
|
||
// 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 = require('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 });
|
||
});
|
||
},
|
||
|
||
// A remote LTC reader (tools/ltc-reader.js, on the machine that can
|
||
// hear the LTC when this one can't) reports here — the one route on
|
||
// this module that accepts writes, so it optionally carries a shared
|
||
// token (Admin → "LTC reader token").
|
||
'POST /ltc': (req, res) => {
|
||
if (!config.ltcEnabled) {
|
||
return void sendJson(res, 409, { error: 'LTC listener is disabled in Admin — enable it to accept reader reports' });
|
||
}
|
||
const token = String(config.ingestToken || '');
|
||
if (token) {
|
||
const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
||
if (given !== token) return void sendJson(res, 401, { error: 'bad or missing token' });
|
||
}
|
||
let body = '';
|
||
req.on('data', (chunk) => {
|
||
body += chunk;
|
||
if (body.length > 4096) {
|
||
sendJson(res, 413, { error: 'body too large' });
|
||
req.destroy();
|
||
}
|
||
});
|
||
req.on('end', () => {
|
||
if (!current) return void sendJson(res, 503, { error: 'module not mounted' });
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(body);
|
||
} catch {
|
||
return void sendJson(res, 400, { error: 'invalid JSON' });
|
||
}
|
||
const problem = current.ingest(parsed);
|
||
if (problem) return void sendJson(res, 400, { error: problem });
|
||
sendJson(res, 200, { ok: true });
|
||
});
|
||
},
|
||
|
||
'GET /stream': (req, res) => {
|
||
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');
|
||
const state = current ? current.getLatest() : clearedState(false);
|
||
res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||
if (!current) return void res.end();
|
||
const { streams } = current;
|
||
// 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 */ }
|
||
streams.add(res);
|
||
req.on('close', () => streams.delete(res));
|
||
},
|
||
};
|
||
},
|
||
};
|