'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)); }, }; }, };