Files
ProdDash/modules/propresenter-now-next/server.js
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00

176 lines
5.9 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)
*/
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
/** 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;
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 lastFrame = '';
const client = createProPresenterClient({
onData(view) {
latest = view;
if (!streams.size) return;
let frame;
try {
frame = JSON.stringify(view);
} 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);
}
}
},
});
client.configure({
enabled,
host,
port,
password: String(config.password || ''),
});
if (enabled) {
client.start();
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: () => latest };
return {
stop() {
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: 'Live status', 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() }));
},
'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();
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));
},
};
},
};