From 3bfea6010a60a7693ef7fc9ab3ad4a851d90b772 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:40 -0400 Subject: [PATCH] ProPresenter Look 1.0.0: server entry --- modules/propresenter-looks/server.js | 242 +++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 modules/propresenter-looks/server.js diff --git a/modules/propresenter-looks/server.js b/modules/propresenter-looks/server.js new file mode 100644 index 0000000..e6ef178 --- /dev/null +++ b/modules/propresenter-looks/server.js @@ -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: } (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)); + }, + }; + }, +};