ProPresenter Look 1.0.0: a new module showing the live audience look #55
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProPresenter Look — server part.
|
||||
*
|
||||
* Polls ProPresenter's network API for the live audience look and fans it
|
||||
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
|
||||
*
|
||||
* GET /state → { state: <view> } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
*
|
||||
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
|
||||
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
|
||||
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
|
||||
* The current look carries a UUID of its own, never one from /v1/looks. Its
|
||||
* `index` points at the saved look it matches, or equals the number of saved
|
||||
* looks when someone has changed it by hand and it matches none.
|
||||
*/
|
||||
|
||||
const POLL_MS = 1000; // the look matters the moment it changes
|
||||
const RETRY_MS = 3000; // while ProPresenter is unreachable
|
||||
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
||||
const REQUEST_TIMEOUT_MS = 2500;
|
||||
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
function emptyView(configured = false) {
|
||||
return {
|
||||
configured,
|
||||
reachable: false,
|
||||
lastError: '',
|
||||
look: null, // { name, modified, screens }
|
||||
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
|
||||
updatedAt: 0, // ms epoch of the last good answer
|
||||
};
|
||||
}
|
||||
|
||||
function errorText(err) {
|
||||
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
|
||||
}
|
||||
|
||||
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
|
||||
function lookName(entry) {
|
||||
return String(entry?.id?.name ?? entry?.name ?? '').trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||
const host = String(ep.host || '').trim();
|
||||
const port = Number(ep.port) || 1025;
|
||||
const password = String(config.password || '');
|
||||
const configured = Boolean(host && port);
|
||||
|
||||
let view = emptyView(configured);
|
||||
let lastFrame = '';
|
||||
let saved = []; // names of the saved looks, in ProPresenter's order
|
||||
let savedOk = false;
|
||||
let tick = 0;
|
||||
let stopped = false;
|
||||
let pollTimer = null;
|
||||
|
||||
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 broadcast() {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Polls land every second whether anything changed or not; only real
|
||||
// changes go out, so idle tiles stay idle.
|
||||
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 refreshSaved() {
|
||||
try {
|
||||
const list = await requestJson('/v1/looks');
|
||||
saved = Array.isArray(list) ? list.map(lookName) : [];
|
||||
savedOk = true;
|
||||
} catch (err) {
|
||||
// An older ProPresenter without the list still answers look/current;
|
||||
// the live look's own name is shown then.
|
||||
if (err.status !== 404) throw err;
|
||||
saved = [];
|
||||
savedOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLook(live) {
|
||||
const index = Number(live?.id?.index);
|
||||
const own = lookName(live);
|
||||
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
|
||||
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
||||
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
|
||||
}
|
||||
// Changed by hand, matching no saved look.
|
||||
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
|
||||
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
if (stopped) return;
|
||||
const next = { ...view };
|
||||
try {
|
||||
tick += 1;
|
||||
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
|
||||
const live = await requestJson('/v1/look/current');
|
||||
let look = resolveLook(live);
|
||||
// A look saved since our last read: refresh the list now rather than
|
||||
// calling it "Custom" for up to ten seconds.
|
||||
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
|
||||
await refreshSaved();
|
||||
look = resolveLook(live);
|
||||
}
|
||||
const before = view.look;
|
||||
if (before && (before.name !== look.name || before.modified !== look.modified)) {
|
||||
next.changedAt = Date.now();
|
||||
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||
} else if (!before) {
|
||||
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||
}
|
||||
next.look = look;
|
||||
next.reachable = true;
|
||||
next.lastError = '';
|
||||
next.updatedAt = Date.now();
|
||||
} catch (err) {
|
||||
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
|
||||
next.reachable = false;
|
||||
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
|
||||
tick = 0; // re-read the saved looks first when it comes back
|
||||
}
|
||||
if (stopped) return;
|
||||
view = next;
|
||||
broadcast();
|
||||
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
log(`following the live look on ${host}:${port}`);
|
||||
pollTick();
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = { streams, getView: () => view };
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(pollTimer);
|
||||
clearInterval(heartbeat);
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||
if (view.reachable) {
|
||||
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
|
||||
}
|
||||
return {
|
||||
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
|
||||
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
|
||||
},
|
||||
|
||||
'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');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\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));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user