The Now / Next and Now tiles' header pill now carries the name of the Audience Look live in ProPresenter where it used to say "Live". The module reads /v1/look/current once a second (and the saved list every tenth poll) with the feed's own endpoint and password, and sends the look as an additive `look` field in its stream and snapshot. A single slow or failed read keeps the last name; the feed's own reachable state is never touched. With no look to show the pill falls back to "Live". The gear switch is relabelled "Audience look" (same key, so saved layouts keep working). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* ProPresenter Now / Next — server part.
|
|
*
|
|
* Owns the one ProPresenter connection (via propresenter-core, copied here
|
|
* unchanged — it holds every ProPresenter 7/20 quirk) and fans the live
|
|
* view-model out to every tile over SSE. Browsers never talk to
|
|
* ProPresenter directly.
|
|
*
|
|
* GET /state → { state: <view-model> } (instant paint for new tiles)
|
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
|
*
|
|
* The view-model carries `look`: the live audience look as
|
|
* { name, modified }, or null while it is unknown. A small poller of this
|
|
* file's own reads it (GET /v1/look/current, with /v1/looks to name it) from
|
|
* the same ProPresenter the core follows. Its failures only null `look`; they
|
|
* never touch the core's reachable / lastError.
|
|
*/
|
|
|
|
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
|
|
|
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
|
const HEARTBEAT_MS = 15000;
|
|
|
|
const LOOK_POLL_MS = 1000; // the look matters the moment it changes
|
|
const LOOK_RETRY_MS = 3000; // while the look cannot be read
|
|
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
|
const LOOK_TIMEOUT_MS = 2500;
|
|
const LOOK_FAILS_BEFORE_NULL = 3; // consecutive failed reads before the look is called unknown
|
|
|
|
/** 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();
|
|
}
|
|
|
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
|
let current = null;
|
|
|
|
module.exports = {
|
|
init({ config, log }) {
|
|
const streams = new Set();
|
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
|
// field existed) can only still be present until the admin form is
|
|
// re-saved, so when they exist they win over the endpoint's default.
|
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
|
const host = String(config.host ?? ep.host ?? '');
|
|
const port = Number(config.port ?? ep.port) || 1025;
|
|
const enabled = Boolean(host && port);
|
|
let latest = clearedViewModel();
|
|
let look = null; // { name, modified } or null while unknown
|
|
let lastFrame = '';
|
|
|
|
/** The core's view-model plus the live look — what every tile and GET /state sees. */
|
|
const snapshot = () => ({ ...latest, look });
|
|
|
|
function broadcast() {
|
|
if (!streams.size) return;
|
|
let frame;
|
|
try {
|
|
frame = JSON.stringify(snapshot());
|
|
} catch {
|
|
return;
|
|
}
|
|
// The poll loop emits every tick whether anything changed or not.
|
|
// Broadcasting only real changes keeps idle tiles genuinely 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
const client = createProPresenterClient({
|
|
onData(view) {
|
|
latest = view;
|
|
broadcast();
|
|
},
|
|
});
|
|
|
|
client.configure({
|
|
enabled,
|
|
host,
|
|
port,
|
|
password: String(config.password || ''),
|
|
});
|
|
|
|
/* ── audience look ──────────────────────────────────────────────
|
|
Same reads and rules as the ProPresenter Look module. ProPresenter
|
|
sometimes leaves the live look's name blank, so the saved list is
|
|
consulted: the look's `index` points at the saved look it matches, or
|
|
is past the end when someone has changed it by hand. */
|
|
|
|
const password = String(config.password || '');
|
|
let saved = []; // names of the saved looks, in ProPresenter's order
|
|
let savedOk = false; // the list exists and is in `saved`
|
|
let savedTried = false; // /v1/looks has answered (a 404 counts: do not ask again every poll)
|
|
let lookTick = 0;
|
|
let lookFails = 0; // consecutive failed reads of the look
|
|
let lookStopped = false;
|
|
let lookTimer = null;
|
|
|
|
function lookUrl(target) {
|
|
const built = new URL(`http://${host}:${port}${target}`);
|
|
if (password) built.searchParams.set('password', password);
|
|
return built;
|
|
}
|
|
|
|
async function lookJson(target) {
|
|
const response = await fetch(lookUrl(target), {
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(LOOK_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;
|
|
}
|
|
|
|
async function refreshSaved() {
|
|
try {
|
|
const list = await lookJson('/v1/looks');
|
|
// A real empty array is a valid (empty) list. Anything else that
|
|
// answered 200 is not a list, so it names nothing — an empty list
|
|
// would call every look modified.
|
|
saved = Array.isArray(list) ? list.map(lookName) : [];
|
|
savedOk = Array.isArray(list);
|
|
savedTried = true;
|
|
} catch (err) {
|
|
// An older ProPresenter without the list still answers look/current;
|
|
// the live look's own name is shown then. Remembered, so the list is
|
|
// asked for again only on the normal cadence, not on every poll.
|
|
if (err.status !== 404) throw err;
|
|
saved = [];
|
|
savedOk = false;
|
|
savedTried = true;
|
|
}
|
|
}
|
|
|
|
/** { name, modified }, or null when there is no name to show (the pill then says "Live"). */
|
|
function resolveLook(live) {
|
|
const index = Number(live?.id?.index);
|
|
const own = lookName(live);
|
|
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
|
return { name: saved[index] || own || `Look ${index + 1}`, modified: false };
|
|
}
|
|
// Changed by hand, matching no saved look.
|
|
if (savedOk && Number.isInteger(index) && index >= saved.length) {
|
|
return { name: own || 'Custom look', modified: true };
|
|
}
|
|
// No saved list to name it from: the live look's own name, or nothing.
|
|
return own ? { name: own, modified: false } : null;
|
|
}
|
|
|
|
function setLook(next) {
|
|
if (JSON.stringify(next) === JSON.stringify(look)) return;
|
|
look = next;
|
|
broadcast();
|
|
}
|
|
|
|
async function pollLook() {
|
|
if (lookStopped) return;
|
|
let next = null;
|
|
let readOk = false;
|
|
try {
|
|
lookTick += 1;
|
|
if (lookTick % LOOKS_EVERY === 1 || !savedTried) await refreshSaved();
|
|
const live = await lookJson('/v1/look/current');
|
|
if (!live || typeof live !== 'object') throw new Error('no look in the answer');
|
|
next = resolveLook(live);
|
|
// A look saved since our last read: refresh the list now rather than
|
|
// calling it "Custom" for up to ten seconds.
|
|
if (next?.modified && !look?.modified && lookTick % LOOKS_EVERY !== 1) {
|
|
await refreshSaved();
|
|
next = resolveLook(live);
|
|
}
|
|
readOk = true;
|
|
} catch {
|
|
// A list that exists may have changed while ProPresenter was away:
|
|
// re-read it first when it comes back. One that is known not to exist
|
|
// (404) is not asked for again on every failed retry.
|
|
if (savedOk) lookTick = 0;
|
|
}
|
|
if (lookStopped) return;
|
|
if (readOk) {
|
|
lookFails = 0;
|
|
setLook(next);
|
|
} else {
|
|
// One slow or dropped read must not blank the pill: the last good look
|
|
// stands until the reads have failed several times running. A real
|
|
// outage shows as "Reconnecting…" through the core, not as this name.
|
|
lookFails += 1;
|
|
if (lookFails >= LOOK_FAILS_BEFORE_NULL) setLook(null);
|
|
}
|
|
lookTimer = setTimeout(pollLook, readOk ? LOOK_POLL_MS : LOOK_RETRY_MS);
|
|
}
|
|
|
|
if (enabled) {
|
|
client.start();
|
|
pollLook();
|
|
log(`following ProPresenter at ${host}:${port}`);
|
|
} 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, getLatest: snapshot };
|
|
|
|
return {
|
|
stop() {
|
|
lookStopped = true;
|
|
clearTimeout(lookTimer);
|
|
clearInterval(heartbeat);
|
|
client.stop();
|
|
for (const res of streams) {
|
|
try { res.end(); } catch { /* already gone */ }
|
|
}
|
|
streams.clear();
|
|
if (current && current.streams === streams) current = null;
|
|
},
|
|
health() {
|
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
|
if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
|
|
return {
|
|
status: 'error',
|
|
message: latest.lastError
|
|
? `ProPresenter unreachable: ${latest.lastError}`
|
|
: 'Trying to reach ProPresenter…',
|
|
};
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Two ways to place this module. "Now / Next" is the full card — what is
|
|
* live, then the rest of the playlist. "Now" is the same live item alone,
|
|
* edge to edge with the gradient filling the tile: a wall display that says
|
|
* one thing. The Now entry offers only the settings it can act on, so its
|
|
* gear carries no Next switches.
|
|
*/
|
|
tiles() {
|
|
return [
|
|
{
|
|
id: 'full',
|
|
name: 'Now / Next',
|
|
description: 'What is live, and the rest of the playlist',
|
|
defaultSize: { w: 4, h: 5 },
|
|
minSize: { w: 3, h: 3 },
|
|
settings: { view: 'full' },
|
|
},
|
|
{
|
|
id: 'now',
|
|
name: 'Now',
|
|
description: 'Only what is live, filling the tile',
|
|
defaultSize: { w: 4, h: 2 },
|
|
minSize: { w: 2, h: 1 },
|
|
settings: { view: 'now' },
|
|
instanceSchema: {
|
|
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
|
|
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
|
|
showLive: { type: 'switch', label: 'Audience look', default: true, group: 'Show' },
|
|
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
|
|
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
|
|
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
|
|
},
|
|
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
|
},
|
|
];
|
|
},
|
|
|
|
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.getLatest() : { ...clearedViewModel(), look: null } }));
|
|
},
|
|
|
|
'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');
|
|
const view = current ? current.getLatest() : { ...clearedViewModel(), look: null };
|
|
res.write(`event: state\ndata: ${JSON.stringify(view)}\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));
|
|
},
|
|
};
|
|
},
|
|
};
|