Timers: LTC timecode via the stage-display websocket
ProPresenter's HTTP API has no timecode route (verified: 21.4 returns 404), but its classic stage-display websocket still answers — so LTC now comes from a stage layout field labeled "LTC": a zero-dependency RFC6455 client (stage-ws.js) authenticates (new stagePassword admin config), resolves the LTC-labeled frame's uid from the layout list, and treats any message carrying that uid as the timecode text. "Receiving" is a 2.5s staleness heuristic; frozen or placeholder text reads as no-signal. The HTTP probe stays authoritative when it ever answers (a 404 verdict is retried once a minute), and the LTC card surfaces the reason when timecode is unavailable (bad stage password, no LTC field). pp-timers-mock grows a /stagedisplay websocket with --stage-pwd and --no-ltc-field to exercise every path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f80790e3a1
commit
444f03938d
@@ -23,10 +23,15 @@
|
||||
* "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. We probe this
|
||||
* candidate route once per connection anyway so a future ProPresenter
|
||||
* that adds it lights up without a module update; a 404 marks LTC
|
||||
* "not available on this ProPresenter" (ltc.supported = false).
|
||||
* 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 actually comes from the classic
|
||||
* stage-display websocket (see stage-ws.js): a stage layout field labeled
|
||||
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever
|
||||
* answers (authoritative), else the stage field, else "not available".
|
||||
*
|
||||
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||||
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||||
@@ -47,6 +52,8 @@ const REQUEST_TIMEOUT_MS = 4000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
const { createStageLtcClient } = require('./stage-ws');
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
@@ -56,9 +63,10 @@ function clearedState(enabled) {
|
||||
reachable: false,
|
||||
lastError: '',
|
||||
timers: [],
|
||||
// supported: null = not probed yet (or ProPresenter unreachable),
|
||||
// true/false once a reachable ProPresenter answered the probe.
|
||||
ltc: { supported: null, time: '', receiving: false },
|
||||
// supported: null = no verdict yet (probing, or ProPresenter
|
||||
// unreachable); true once a timecode source is live; false when both
|
||||
// sources came up empty (note says why, when we know).
|
||||
ltc: { supported: null, time: '', receiving: false, source: '', note: '' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +93,23 @@ module.exports = {
|
||||
/** 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: '' };
|
||||
|
||||
/** One LTC verdict from both sources: HTTP route wins if it ever
|
||||
answers (authoritative), else the stage-display field. */
|
||||
function composeLtc() {
|
||||
if (ltcSupported === true) {
|
||||
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' };
|
||||
}
|
||||
if (stageLtc.bound) {
|
||||
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' };
|
||||
}
|
||||
if (ltcSupported === null && !stageLtc.note) {
|
||||
return { supported: null, source: '', time: '', receiving: false, note: '' };
|
||||
}
|
||||
return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' };
|
||||
}
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
@@ -197,7 +222,10 @@ module.exports = {
|
||||
}
|
||||
|
||||
async function pollLtc() {
|
||||
if (ltcSupported === false) 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');
|
||||
@@ -221,7 +249,7 @@ module.exports = {
|
||||
reachable: latest.reachable,
|
||||
lastError: latest.lastError,
|
||||
timers: latest.timers,
|
||||
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
|
||||
ltc: composeLtc(),
|
||||
};
|
||||
if (!streams.size) return;
|
||||
let frame;
|
||||
@@ -271,9 +299,20 @@ module.exports = {
|
||||
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
let stageClient = null;
|
||||
if (enabled) {
|
||||
pollTick();
|
||||
log(`polling timers on ${host}:${port}`);
|
||||
stageClient = createStageLtcClient({
|
||||
host,
|
||||
port,
|
||||
password: String(config.stagePassword || ''),
|
||||
log,
|
||||
onUpdate(update) {
|
||||
stageLtc = update;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
@@ -296,6 +335,7 @@ module.exports = {
|
||||
stopped = true;
|
||||
clearTimeout(pollTimer);
|
||||
clearInterval(heartbeat);
|
||||
stageClient?.stop();
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
@@ -305,7 +345,10 @@ module.exports = {
|
||||
health() {
|
||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||
if (latest.reachable) {
|
||||
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
|
||||
const ltc = latest.ltc;
|
||||
const ltcNote = ltc.supported
|
||||
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API')
|
||||
: (ltc.note ? ` (${ltc.note})` : '');
|
||||
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user