Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5 64502261ff Timers: version 1.1.0 (stage-display LTC), per the new versioning rules
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 09:26:50 -04:00
Waters Church 6427840223 Merge main: per-machine data dir, admin installer, module version requirements 2026-09-10 09:26:35 -04:00
Waters ChurchandClaude Fable 5 d5cb6b6cd9 Timers: heuristically bind LTC when the stage layout omits the field
Verified against ProPresenter 21.4: the legacy stage-display protocol
(ptl 610 — the only version PP21 accepts; newer values get 'out of
date') omits stage objects it predates, so a timecode field labeled LTC
never appears in any layout's frame list, and no timecode messages were
observed while idle. Fallback: an unknown uid streaming timecode-shaped
text (a fourth :FF group; not any configured timer's uuid) is bound as
LTC after two advancing samples, and that binding survives layout
refreshes. pp-timers-mock gains --ltc-unlisted to exercise the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 17:05:23 -04:00
Waters ChurchandClaude Fable 5 444f03938d 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>
2026-08-31 17:05:23 -04:00
9 changed files with 629 additions and 67 deletions
+9 -9
View File
@@ -140,8 +140,8 @@ below. Nothing is installed without someone clicking.
sit flush — when two share a vertical edge, hover the seam and the same
kind of pill appears; drag it to trade width (macOS split-view style).
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the
green tab brings it back, minus the Admin button), the occupied columns
stretch to the full width, and every column runs to the
bottom of the screen — the last tile in each column is stretched to end
there. A short tile like the clock is never stretched into a ribbon: under
another tile it slides to the bottom edge and the tile above grows into the
@@ -150,11 +150,9 @@ below. Nothing is installed without someone clicking.
its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**;
the small **notch** on the bar's bottom edge hides/shows the whole bar
for a clean wall-display look. The menu bar's handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's
own tab.
for a clean wall-display look. The menu bar has the same kind of tab —
the green one under the ProdDash name, kept at the left so it never sits
on top of a tile's own tab.
- The **gear** on a tile holds per-tile settings (filters, text size,
display options) — these belong to this browser's layout, not the server.
- The **grid icon** — save the current arrangement to the server under a
@@ -177,8 +175,10 @@ below. Nothing is installed without someone clicking.
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config:
ProPresenter endpoint (host : port), password.
unmistakably red. LTC is read from a stage-display layout field labeled
"LTC" over ProPresenter's stage websocket (its HTTP API has no timecode
route). Read-only — no start/stop controls. Admin config: ProPresenter
endpoint (host : port), API password, stage display password.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
+3 -1
View File
@@ -496,7 +496,9 @@ client-side failures (a tile that fails to start says so in the tile).
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
overrunning, stopped and elapsed timers plus an LTC timecode that
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
a timecode API). Also serves the stage-display websocket with an
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
**The resilience checklist** — every module must pass this before it's done:
+3 -1
View File
@@ -210,7 +210,9 @@ export default function create({ root, moduleApi }) {
if (ltc.supported === false) {
mode = 'unavailable';
card.timeEl.textContent = '—';
card.stateEl.textContent = 'Timecode not available on this ProPresenter';
// The server says why when it knows (stage password rejected, no
// stage field labeled LTC, …) — surface that over the generic line.
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
} else if (ltc.supported === null) {
mode = 'waiting';
card.timeEl.textContent = displayTime(ltc.time);
+18 -4
View File
@@ -1,24 +1,38 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.0.0",
"version": "1.1.0",
"proddash": ">=1.1.0",
"description": "Live ProPresenter timers and LTC timecode",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 },
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"default": ""
},
"stagePassword": {
"type": "password",
"label": "Stage display password (for LTC — ProPresenter Settings → Network)",
"default": ""
}
}
}
+56 -10
View File
@@ -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,23 @@ 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,
// 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');
}
@@ -296,6 +338,7 @@ module.exports = {
stopped = true;
clearTimeout(pollTimer);
clearInterval(heartbeat);
stageClient?.stop();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
@@ -305,7 +348,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 {
+379
View File
@@ -0,0 +1,379 @@
'use strict';
/**
* ProPresenter stage-display websocket client — the LTC timecode source.
*
* ProPresenter's HTTP openAPI exposes no timecode route (see server.js), but
* its classic stage-display websocket (ws://host:port/stagedisplay — the
* Pro6-era protocol, still answered by ProPresenter 21; verified live: it
* responds `{"acn":"ath","ath":…,"majorVersion":7,"minorVersion":21}`) streams
* the text of stage-layout fields. So: the operator puts a field labeled
* "LTC" on a stage layout, and this client reads that field's live text.
*
* Protocol (community-documented, github.com/jeffmikels/ProPresenter-API):
* → {"pwd":"<stage password>","ptl":610,"acn":"ath"}
* ← {"acn":"ath","ath":true|false,"err":"…"}
* → {"acn":"psl"} ← {"acn":"psl","uid":"<layout uid>"}
* → {"acn":"asl"} ← {"acn":"asl","ary":[{acn:"sl",uid,nme,fme:[…]},…]}
* ← {"acn":"sys","txt":" 11:17 AM"} every second — our liveness signal
* ← {"acn":"tmr","uid":"…","txt":"…"} live text for a layout field's uid
*
* A layout's `fme` frames carry `nme` (the label) and, for linked fields, a
* `uid`. We bind to the first frame labeled "LTC" (case-insensitive), then
* treat any message carrying that uid as the timecode text — matching by uid
* rather than acn on purpose, since how ProPresenter 21 tags timecode
* updates is undocumented. "Receiving" is a staleness heuristic: LTC text
* that hasn't advanced for STALE_MS is a frozen/no-signal feed.
*
* Zero-dependency by design (like the rest of ProdDash): a minimal RFC6455
* client — masked client frames, text + ping/pong + close, fragmented text
* reassembly. TLS is not needed (ProPresenter's server is plain ws://).
*/
const net = require('net');
const crypto = require('crypto');
const RECONNECT_MS = 3000;
/** Auth rejections retry slowly — a wrong password never fixes itself fast. */
const BAD_AUTH_RETRY_MS = 30000;
/** ProPresenter sends a clock tick every second; silence this long = dead link. */
const LIVENESS_MS = 10000;
/** LTC text that has not advanced for this long counts as no-signal. */
const STALE_MS = 2500;
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const ZERO_UID = '00000000-0000-0000-0000-000000000000';
/** Timecode-shaped text: a fourth (frames) group separates it from timers. */
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
/**
* @param {{host:string, port:number, password:string, log:Function,
* isTimerUid?:(uid:string)=>boolean,
* onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts
* @returns {{stop:()=>void}}
*/
function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) {
let sock = null;
let stopped = false;
let reconnectTimer = null;
let livenessTimer = null;
let staleTimer = null;
let ltcUid = ''; // uid of the frame labeled LTC, once bound
let boundBy = ''; // 'label' (layout frame) or 'heuristic' (sniffed)
let candidateUid = ''; // heuristic candidate awaiting a second, advancing sample
let candidateText = '';
let lastText = '';
let lastAdvance = 0;
// Known-but-unused message types; anything else is logged once so a new
// ProPresenter's timecode tag shows up in the server log for discovery.
const loggedUnknownAcn = new Set(['fv', 'msg', 'vid', 'tmr', 'cs', 'ns', 'csn', 'nsn']);
function emit(partial) {
onUpdate({
bound: Boolean(ltcUid),
time: lastText,
receiving: false,
note: '',
...partial,
});
}
/* ── outgoing frames (client → server must be masked) ───────────── */
function sendFrame(opcode, payload) {
if (!sock || sock.destroyed) return;
const mask = crypto.randomBytes(4);
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.from([0x80 | opcode, 0x80 | len]);
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
const masked = Buffer.from(payload);
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
try {
sock.write(Buffer.concat([header, mask, masked]));
} catch { /* socket died mid-write; liveness/close handling reconnects */ }
}
function sendJson(obj) {
sendFrame(0x1, Buffer.from(JSON.stringify(obj)));
}
/* ── protocol: layouts → find the LTC-labeled frame ─────────────── */
let assignedLayoutUid = '';
function bindFromLayouts(layouts) {
const isLtcFrame = (f) => String(f?.nme || '').trim().toLowerCase() === 'ltc';
const pick = (layout) => (Array.isArray(layout?.fme) ? layout.fme.find(isLtcFrame) : null);
// Prefer the layout ProPresenter assigned to this client; fall back to
// any layout with an LTC-labeled frame.
const ordered = layouts.slice().sort((a, b) =>
(b.uid === assignedLayoutUid ? 1 : 0) - (a.uid === assignedLayoutUid ? 1 : 0));
for (const layout of ordered) {
const frame = pick(layout);
if (frame && frame.uid) {
if (ltcUid !== frame.uid) {
ltcUid = String(frame.uid);
boundBy = 'label';
log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`);
emit({ receiving: false });
}
return;
}
if (frame && !frame.uid) {
log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`);
}
}
// Verified on ProPresenter 21.4: the legacy protocol (the only version
// it accepts, ptl 610) omits stage objects it predates — a timecode
// field never appears in any layout's frame list. So a heuristic
// binding sniffed from the live stream must survive layout refreshes.
if (boundBy === 'heuristic' && ltcUid) return;
ltcUid = '';
boundBy = '';
emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' });
}
function handleMessage(obj) {
const acn = String(obj.acn || '');
switch (acn) {
case 'ath':
if (obj.ath === true) {
log('stage display: authenticated');
sendJson({ acn: 'psl' }); // which layout are we assigned?
sendJson({ acn: 'asl' }); // all layouts, with their frames
} else {
const err = String(obj.err || 'authentication rejected');
log(`stage display: auth failed — ${err}`);
emit({ bound: false, time: '', note: 'Stage display password rejected — set it in Admin' });
scheduleReconnect(BAD_AUTH_RETRY_MS);
destroySocket();
}
return;
case 'psl':
assignedLayoutUid = String(obj.uid || '');
return;
case 'asl':
if (Array.isArray(obj.ary)) bindFromLayouts(obj.ary);
return;
case 'sl':
// A layout definition was pushed (assignment or edit) — it may not
// be ours, so re-resolve from the authoritative full list instead
// of rebinding blindly.
sendJson({ acn: 'psl' });
sendJson({ acn: 'asl' });
return;
case 'sys': // per-second clock — liveness only
return;
default:
break;
}
// Heuristic fallback: with no LTC-labeled frame to bind to, an unknown
// uid streaming timecode-shaped text (a fourth :FF group — timers never
// have one) that is NOT a configured timer can only be the timecode
// field. Require two advancing samples before trusting it.
if (!ltcUid && obj.uid && obj.uid !== ZERO_UID && typeof obj.txt === 'string'
&& TC_SHAPE.test(obj.txt.trim()) && !(isTimerUid && isTimerUid(obj.uid))) {
if (candidateUid === obj.uid && candidateText !== obj.txt) {
ltcUid = String(obj.uid);
boundBy = 'heuristic';
log(`stage display: no LTC-labeled field in any layout, but ${obj.uid} streams timecode-shaped text (acn "${acn}") — binding it as LTC`);
// fall through to the normal update path below
} else {
candidateUid = String(obj.uid);
candidateText = obj.txt;
return;
}
}
// Anything carrying our bound uid is the LTC text, whatever its acn —
// ProPresenter 21's tag for timecode fields is undocumented.
if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') {
const txt = obj.txt.trim();
const placeholder = !txt || /^[-–—:;.\s]*$/.test(txt);
if (placeholder) {
lastText = '';
emit({ receiving: false });
return;
}
if (txt !== lastText) {
lastText = txt;
lastAdvance = Date.now();
emit({ receiving: true });
clearTimeout(staleTimer);
staleTimer = setTimeout(() => {
// Frozen text = LTC stopped arriving at ProPresenter.
emit({ receiving: false });
}, STALE_MS);
staleTimer.unref?.();
}
return;
}
if (acn && !loggedUnknownAcn.has(acn) && loggedUnknownAcn.size < 30) {
loggedUnknownAcn.add(acn);
log(`stage display: unhandled message type "${acn}" — ${JSON.stringify(obj).slice(0, 160)}`);
}
}
/* ── incoming frames ────────────────────────────────────────────── */
function connect() {
if (stopped) return;
let buf = Buffer.alloc(0);
let upgraded = false;
let fragments = null;
const key = crypto.randomBytes(16).toString('base64');
sock = net.connect(port, host);
sock.setNoDelay(true);
const bumpLiveness = () => {
clearTimeout(livenessTimer);
livenessTimer = setTimeout(() => {
log('stage display: silent too long — reconnecting');
destroySocket();
scheduleReconnect(RECONNECT_MS);
}, LIVENESS_MS);
livenessTimer.unref?.();
};
sock.on('connect', () => {
sock.write(
`GET /stagedisplay HTTP/1.1\r\n` +
`Host: ${host}:${port}\r\n` +
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
bumpLiveness();
});
sock.on('data', (chunk) => {
bumpLiveness();
buf = Buffer.concat([buf, chunk]);
if (!upgraded) {
const idx = buf.indexOf('\r\n\r\n');
if (idx === -1) return;
const head = buf.slice(0, idx).toString();
buf = buf.slice(idx + 4);
const statusLine = head.split('\r\n')[0] || '';
if (!/ 101 /.test(statusLine + ' ')) {
log(`stage display: no websocket upgrade (${statusLine.trim()}) — this ProPresenter may not serve /stagedisplay`);
emit({ bound: false, time: '', note: 'No stage display websocket on this ProPresenter' });
destroySocket();
scheduleReconnect(BAD_AUTH_RETRY_MS);
return;
}
const accept = crypto.createHash('sha1').update(key + WS_MAGIC).digest('base64');
const m = head.match(/sec-websocket-accept:\s*(\S+)/i);
if (m && m[1] !== accept) log('stage display: Sec-WebSocket-Accept mismatch (continuing)');
upgraded = true;
sendJson({ pwd: String(password || ''), ptl: 610, acn: 'ath' });
}
// decode server frames (unmasked; tolerate masked anyway)
while (buf.length >= 2) {
const fin = (buf[0] & 0x80) !== 0;
const opcode = buf[0] & 0x0f;
const maskBit = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) return;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
const maskLen = maskBit ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (maskBit) {
const mask = buf.slice(off, off + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
buf = buf.slice(off + maskLen + len);
if (opcode === 0x9) { // ping → pong
sendFrame(0xA, payload);
continue;
}
if (opcode === 0x8) { // close
destroySocket();
scheduleReconnect(RECONNECT_MS);
return;
}
if (opcode === 0x1 || (opcode === 0x0 && fragments)) {
if (!fin || opcode === 0x0) { // fragmented text
fragments = fragments ? Buffer.concat([fragments, payload]) : payload;
if (!fin) continue;
payload = fragments;
fragments = null;
}
let obj;
try {
obj = JSON.parse(payload.toString());
} catch {
continue;
}
if (obj && typeof obj === 'object') handleMessage(obj);
}
// binary frames (slide images) are ignored
}
});
sock.on('error', () => { /* close handler reconnects */ });
sock.on('close', () => {
clearTimeout(livenessTimer);
if (stopped) return;
if (!reconnectTimer) {
emit({ receiving: false });
scheduleReconnect(RECONNECT_MS);
}
});
}
function destroySocket() {
if (sock) {
try { sock.destroy(); } catch { /* already gone */ }
sock = null;
}
}
function scheduleReconnect(ms) {
if (stopped || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, ms);
reconnectTimer.unref?.();
}
connect();
return {
stop() {
stopped = true;
clearTimeout(reconnectTimer);
clearTimeout(livenessTimer);
clearTimeout(staleTimer);
destroySocket();
},
};
}
module.exports = { createStageLtcClient };
+2 -3
View File
@@ -15,8 +15,7 @@
</head>
<body>
<header id="topbar">
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
<span id="brand">Prod<span class="accent">Dash</span></span>
<span class="spacer"></span>
@@ -51,7 +50,7 @@
</a>
</header>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
<main id="gridwrap">
<div id="grid">
+20 -38
View File
@@ -54,54 +54,36 @@ body.menu-hidden #topbar {
overflow: hidden;
}
/* The menubar's show/hide handle IS the logo: a translucent accent pill
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
background colour. It sits at the left — never screen-centre, where a
tile's own (centred) tab lands once both bars are hidden. When the bar
hides, the pill shrinks to a slim lip at the very top edge, clear of a
top-row tile's status dot and title. */
.brand-spacer { visibility: hidden; }
/* The menubar's own show/hide tab. It sits under the ProdDash brand at the
left — not screen-centre, where a tile's own (centred) tab would land
right under it once both bars are hidden — and wears the accent colour
so it never reads as a tile tab. */
#topbar-notch {
position: fixed;
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
8px past the bar's bottom edge; the logo sits centred in the bar part */
top: 0;
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
height: calc(var(--topbar-h, 46px) + 8px);
min-width: 60px;
padding: 0 10px;
font: inherit;
font-weight: 800;
font-size: 15px;
letter-spacing: 0.02em;
line-height: var(--topbar-h, 46px);
color: var(--text);
background: color-mix(in srgb, var(--accent) 45%, transparent);
/* straddles the bar's bottom edge — mostly on the bar, a short lip below
it — so it never covers a top-row tile's status dot or title */
top: calc(var(--topbar-h, 46px) - 8px);
left: 12px;
width: 54px;
height: 14px;
padding: 0;
background: var(--accent);
border: none;
border-radius: 0 0 12px 12px;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 60;
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
opacity: 0.55;
transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease;
}
#topbar-notch .accent { color: var(--bg); } /* the cutout */
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
body.menu-hidden #topbar-notch {
height: 10px;
line-height: 10px;
font-size: 0;
background: color-mix(in srgb, var(--accent) 55%, transparent);
}
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
body.menu-hidden #topbar-notch::after {
#topbar-notch:hover { opacity: 1; }
/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */
body.menu-hidden #topbar-notch { top: 0; height: 10px; }
#topbar-notch::after {
content: "";
display: block;
width: 20px;
height: 3px;
margin: 0 auto;
position: relative;
top: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--bg);
}
+139 -1
View File
@@ -4,6 +4,7 @@
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
*
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
* [--stage-pwd=<pwd>] [--no-ltc-field]
*
* Serves the endpoint shapes verified against openapi.propresenter.com:
* GET /version (reachability probe)
@@ -23,6 +24,16 @@
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
* ProPresenter (a solo tile for it must say so, not error).
*
* Also serves the classic stage-display websocket at /stagedisplay (the
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
* per-second "sys" clock, and "tmr" text updates — including a stage layout
* with a field labeled "LTC" whose text advances at 30 fps and freezes
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
* with --no-ltc to make the stage feed the only timecode source (the module
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
* --no-ltc-field serves the stage layout without the LTC field.
*
* Every incoming request is logged so we can confirm what the module fetches.
*/
'use strict';
@@ -33,6 +44,11 @@ const args = process.argv.slice(2);
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
const NO_LTC_FIELD = args.includes('--no-ltc-field');
// Like real ProPresenter 21: the layout list omits the timecode field, but
// its updates still stream — exercises the module's heuristic binding.
const LTC_UNLISTED = args.includes('--ltc-unlisted');
const FPS = 30;
const startedAt = Date.now();
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
sendJson(res, { error: 'not found' }, 404);
});
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const STAGE_LAYOUT = {
acn: 'sl',
uid: 'MOCK-LAYOUT-1',
nme: 'Timers',
fme: [
{ nme: 'Current Slide', typ: 1 },
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
],
};
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
function wsEncode(str) {
const p = Buffer.from(str);
let h;
if (p.length < 126) {
h = Buffer.from([0x81, p.length]);
} else {
h = Buffer.alloc(4);
h[0] = 0x81;
h[1] = 126;
h.writeUInt16BE(p.length, 2);
}
return Buffer.concat([h, p]);
}
server.on('upgrade', (req, socket) => {
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
const key = req.headers['sec-websocket-key'];
if (!key) return void socket.destroy();
console.log('[pp-timers-mock] stage display client connected');
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
const send = (obj) => {
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
};
let authed = false;
let buf = Buffer.alloc(0);
let lastLtcText = '';
const sysTimer = setInterval(() => {
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
}, 1000);
const tmrTimer = setInterval(() => {
if (!authed) return;
// noise a client must filter out: an ordinary timer's text
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
}, 1000);
const ltcTimer = setInterval(() => {
if (!authed || NO_LTC_FIELD) return;
const tc = timecodeStatus();
// During no-signal the on-stage field is frozen: no updates at all —
// exactly what the module's staleness heuristic must catch.
if (!tc.receiving || tc.time === lastLtcText) return;
lastLtcText = tc.time;
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
}, 250);
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= 2) {
const opcode = buf[0] & 0x0f;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) return;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (masked) {
const mask = buf.slice(off, off + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
buf = buf.slice(off + maskLen + len);
if (opcode === 0x8) return void socket.destroy();
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
if (opcode !== 0x1) continue;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { continue; }
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
if (msg.acn === 'ath') {
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
} else {
authed = true;
send({ acn: 'ath', ath: true, err: '' });
}
} else if (msg.acn === 'psl') {
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
} else if (msg.acn === 'asl') {
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
}
}
});
const cleanup = () => {
clearInterval(sysTimer);
clearInterval(tmrTimer);
clearInterval(ltcTimer);
};
socket.on('close', cleanup);
socket.on('error', () => { cleanup(); socket.destroy(); });
});
server.listen(PORT, () => {
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`);
});