ProPresenter Look 1.0.0: a new module showing the live audience look #55

Merged
christian merged 6 commits from claude/propresenter-looks into main 2026-10-26 13:05:54 -04:00
6 changed files with 757 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# ProPresenter Look — a ProdDash module
Shows the audience **look** live in ProPresenter right now, as big as the
tile allows, so anyone away from the ProPresenter machine can tell at a glance
which look the outputs are on.
- The look's name fills the tile and **flashes** when it changes.
- **Modified** appears when someone changed the look by hand and it no longer
matches any saved look.
- **since 10:42 AM** says when the look last changed.
- If ProPresenter stops answering, the status dot goes red and the tile keeps
the **last known** look on screen, dimmed, until it comes back by itself.
## Install
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
ProdDash module guide, it installs one of two ways:
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
at `modules/propresenter-looks/`: `/admin` → Available modules →
*ProPresenter Look* → Install.
2. **Drop it in**: copy this folder to the ProdDash data directory's
`modules/propresenter-looks/` and restart ProdDash.
- macOS: `~/Library/Application Support/ProdDash/modules/`
- Windows: `%APPDATA%\ProdDash\modules\`
Then add a tile from **+ Add tile → ProPresenter Look**.
## Configure (in `/admin`)
| Setting | What to enter |
| --- | --- |
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
| Password | Only if ProPresenter's network API asks for one. |
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
## How it works
The server entry polls ProPresenter's network API once a second
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
saved look it matches) and streams the result to tiles over SSE. Browsers
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
ProPresenter 7.9+ (or 20+).
## Testing without ProPresenter
```bash
node tools/pp-looks-mock.js 1601
```
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
look. `--password <pw>` demands a password; `--no-list` acts like a
ProPresenter without `GET /v1/looks`.
+192
View File
@@ -0,0 +1,192 @@
/* ProPresenter Look — client part.
The server part polls ProPresenter and streams the live audience look
here over SSE; the tile only renders. The look's name fills the tile so
it reads from across the booth, flashes once when it changes, and stays
on screen dimmed — marked "last known" — while ProPresenter is
unreachable. */
export default function create({ root, moduleApi }) {
let view = null;
let feedOffline = false;
let stream = null;
let resizeObserver = null;
let flashTimer = null;
let lastName = null;
root.innerHTML = `
<div class="pl-screen">
<header class="pl-top">
<div class="pl-heading">Look</div>
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
</header>
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
<footer class="pl-meta">
<span class="pl-tag" hidden>Modified</span>
<span class="pl-since" hidden></span>
</footer>
</div>`;
const screenEl = root.querySelector('.pl-screen');
const topEl = root.querySelector('.pl-top');
const headingEl = root.querySelector('.pl-heading');
const liveEl = root.querySelector('.pl-live');
const liveTextEl = root.querySelector('.pl-live-text');
const nameBox = root.querySelector('.pl-name-box');
const nameEl = root.querySelector('.pl-name');
const metaEl = root.querySelector('.pl-meta');
const tagEl = root.querySelector('.pl-tag');
const sinceEl = root.querySelector('.pl-since');
const on = (key) => moduleApi.instanceSettings[key] !== false;
function timeText(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/** Biggest font size at which the name fits its box without breaking a word. */
function fitName() {
const MIN = 11;
const boxHeight = nameBox.clientHeight;
const boxWidth = nameBox.clientWidth;
nameEl.style.overflowWrap = 'normal';
if (boxHeight < MIN || !boxWidth) {
nameEl.style.fontSize = `${MIN}px`;
return;
}
const fits = (size) => {
nameEl.style.fontSize = `${size}px`;
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
};
let lo = MIN;
let hi = Math.max(MIN, Math.floor(boxHeight));
let best = MIN;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
nameEl.style.fontSize = `${best}px`;
// One word too long even at MIN: let it break rather than overflow.
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
}
function flash() {
screenEl.classList.remove('is-changed');
void screenEl.offsetWidth; // restart the animation
screenEl.classList.add('is-changed');
clearTimeout(flashTimer);
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
}
function setStatus() {
if (feedOffline) {
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
} else if (!view) {
moduleApi.setStatus('connecting', 'Connecting…');
} else if (!view.configured) {
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
} else if (view.reachable) {
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
} else if (!view.updatedAt && !view.lastError) {
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
} else {
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
}
}
function render() {
setStatus();
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
const look = view && view.look;
screenEl.classList.toggle('is-offline', offline);
headingEl.hidden = !on('showHeading');
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
topEl.hidden = headingEl.hidden && liveEl.hidden;
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
liveEl.classList.toggle('is-down', offline);
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
else if (view && view.reachable) liveTextEl.textContent = 'Live';
else liveTextEl.textContent = 'Connecting…';
let name;
let waiting = false;
if (look) {
name = look.name;
} else if (view && !view.configured) {
name = 'Set up in Admin';
waiting = true;
} else if (offline) {
name = 'ProPresenter unreachable';
waiting = true;
} else {
name = 'Connecting…';
waiting = true;
}
nameEl.textContent = name;
nameEl.classList.toggle('is-waiting', waiting);
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
if (look) lastName = `${look.name}|${look.modified}`;
tagEl.hidden = !(look && look.modified);
const since = look && view.changedAt && on('showSince');
sinceEl.hidden = !since;
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
fitName();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
feedOffline = false;
render();
},
error() {
feedOffline = true;
render();
},
events: {
state(e) {
try {
view = JSON.parse(e.data);
} catch {
return;
}
feedOffline = false;
render();
},
},
});
}
return {
start() {
render();
moduleApi.fetch('/state')
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body && body.state && !view) {
view = body.state;
render();
}
})
.catch(() => {});
connect();
resizeObserver = new ResizeObserver(() => fitName());
resizeObserver.observe(nameBox);
},
stop() {
if (stream) stream.close();
stream = null;
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = null;
clearTimeout(flashTimer);
root.innerHTML = '';
},
};
}
+58
View File
@@ -0,0 +1,58 @@
{
"id": "propresenter-looks",
"name": "ProPresenter Look",
"version": "1.0.0",
"proddash": ">=1.5.0",
"description": "The audience look live in ProPresenter right now",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": {
"host": "",
"port": 1025
},
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
},
"password": {
"type": "password",
"label": "Password",
"default": "",
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
}
},
"instanceSchema": {
"showHeading": {
"type": "switch",
"label": "Heading",
"default": true
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true
},
"showSince": {
"type": "switch",
"label": "Since",
"default": true
}
}
}
+242
View File
@@ -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));
},
};
},
};
+141
View File
@@ -0,0 +1,141 @@
/* ProPresenter Look — the live look's name, as big as the tile allows.
Colors come from the shell's variables only. */
[data-module="propresenter-looks"] .pl-screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
user-select: none;
-webkit-user-select: none;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--bg) 60%, transparent)
);
}
/* ── top row: heading and live status ── */
[data-module="propresenter-looks"] .pl-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
min-height: 0;
}
[data-module="propresenter-looks"] .pl-top[hidden],
[data-module="propresenter-looks"] .pl-heading[hidden],
[data-module="propresenter-looks"] .pl-live[hidden],
[data-module="propresenter-looks"] .pl-meta[hidden],
[data-module="propresenter-looks"] .pl-tag[hidden],
[data-module="propresenter-looks"] .pl-since[hidden] {
display: none;
}
[data-module="propresenter-looks"] .pl-heading {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-looks"] .pl-live-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--border);
transition: background 0.3s ease;
}
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
background: var(--danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
}
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
color: var(--danger);
}
/* ── the look's name ── */
[data-module="propresenter-looks"] .pl-name-box {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
[data-module="propresenter-looks"] .pl-name {
max-width: 100%;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-align: center;
white-space: normal;
word-break: normal;
hyphens: none;
color: var(--text);
}
[data-module="propresenter-looks"] .pl-name.is-waiting {
font-weight: 600;
color: var(--muted);
}
/* Unreachable: the last known look stays, visibly stale. */
[data-module="propresenter-looks"] .pl-screen.is-offline {
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
opacity: 0.45;
}
/* ── footer: modified tag and when it changed ── */
[data-module="propresenter-looks"] .pl-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-looks"] .pl-tag {
padding: 1px 7px;
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 10px;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 16%, transparent);
}
/* A new look: one bright pulse so a glance catches the change. */
[data-module="propresenter-looks"] .pl-screen.is-changed {
animation: pl-changed 2.6s ease-out;
}
@keyframes pl-changed {
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
'use strict';
/**
* Mock ProPresenter looks API, for developing the ProPresenter Look module
* without the real thing.
*
* node tools/pp-looks-mock.js [port] (default 1601)
* --every <seconds> how often the live look changes (default 8)
* --password <pw> demand ?password=<pw> like a locked ProPresenter
* --no-list answer 404 for /v1/looks (an older ProPresenter)
*
* Point the module at host 127.0.0.1 and this port. Every few changes the
* live look is "modified by hand", so it matches no saved look.
*/
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const flag = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
const every = Number(flag('--every')) || 8;
const password = flag('--password') || '';
const noList = args.includes('--no-list');
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
id: { uuid: crypto.randomUUID(), name, index },
screens: [screen(true), screen(index !== 4)],
}));
let live = 0;
let modified = false;
let changes = 0;
const liveUuid = crypto.randomUUID();
setInterval(() => {
changes += 1;
modified = changes % 4 === 0;
if (!modified) live = (live + 1) % saved.length;
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
}, every * 1000);
function currentLook() {
return {
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
screens: saved[live].screens,
};
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const send = (code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(body === undefined ? '' : JSON.stringify(body));
};
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
if (url.pathname === '/v1/look/current') return send(200, currentLook());
send(404);
}).listen(port, () => {
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
console.log(`live look → ${saved[live].id.name}`);
});