From 7c0579cbf970bf0c3b37e966646c21260c566fc5 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:36 -0400 Subject: [PATCH 1/7] ProPresenter Look 1.0.0: manifest --- modules/propresenter-looks/module.json | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 modules/propresenter-looks/module.json diff --git a/modules/propresenter-looks/module.json b/modules/propresenter-looks/module.json new file mode 100644 index 0000000..f4011d1 --- /dev/null +++ b/modules/propresenter-looks/module.json @@ -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 + } + } +} From 3bfea6010a60a7693ef7fc9ab3ad4a851d90b772 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:40 -0400 Subject: [PATCH 2/7] 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)); + }, + }; + }, +}; From 09f59c1bef394f7ff082b5986b1b85db1d7c5935 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:53 -0400 Subject: [PATCH 3/7] ProPresenter Look 1.0.0: client entry --- modules/propresenter-looks/client.js | 192 +++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 modules/propresenter-looks/client.js diff --git a/modules/propresenter-looks/client.js b/modules/propresenter-looks/client.js new file mode 100644 index 0000000..785337d --- /dev/null +++ b/modules/propresenter-looks/client.js @@ -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 = ` +
+
+
Look
+
Connecting…
+
+
Connecting…
+
+ + +
+
`; + + 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 = ''; + }, + }; +} From cd13ecaf7c7ad729ec42bd4589005195a7a7077b Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:12 -0400 Subject: [PATCH 4/7] ProPresenter Look 1.0.0: stylesheet --- modules/propresenter-looks/style.css | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 modules/propresenter-looks/style.css diff --git a/modules/propresenter-looks/style.css b/modules/propresenter-looks/style.css new file mode 100644 index 0000000..4a5bb8a --- /dev/null +++ b/modules/propresenter-looks/style.css @@ -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; } +} From 70f56c7909c5a3155397fa09652d917f01645816 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:25 -0400 Subject: [PATCH 5/7] ProPresenter Look 1.0.0: README --- modules/propresenter-looks/README.md | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 modules/propresenter-looks/README.md diff --git a/modules/propresenter-looks/README.md b/modules/propresenter-looks/README.md new file mode 100644 index 0000000..cff2841 --- /dev/null +++ b/modules/propresenter-looks/README.md @@ -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 ` demands a password; `--no-list` acts like a +ProPresenter without `GET /v1/looks`. From 49f8a82fbd39bbced3bb478aefd7c20ff26403ca Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:39 -0400 Subject: [PATCH 6/7] ProPresenter Look 1.0.0: mock ProPresenter looks API --- .../propresenter-looks/tools/pp-looks-mock.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 modules/propresenter-looks/tools/pp-looks-mock.js diff --git a/modules/propresenter-looks/tools/pp-looks-mock.js b/modules/propresenter-looks/tools/pp-looks-mock.js new file mode 100644 index 0000000..a268f03 --- /dev/null +++ b/modules/propresenter-looks/tools/pp-looks-mock.js @@ -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 how often the live look changes (default 8) + * --password demand ?password= 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}`); +}); From 7c4217a23a9300ba21359daaf83130e90026a785 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 27 Oct 2026 13:08:44 -0400 Subject: [PATCH 7/7] ProPresenter 1.2.0: the live pill shows the audience look 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 --- README.md | 4 +- modules/propresenter-now-next/client.js | 15 +- modules/propresenter-now-next/module.json | 4 +- modules/propresenter-now-next/server.js | 187 +++++++++++++++++++--- modules/propresenter-now-next/style.css | 18 +++ 5 files changed, 200 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 56bcd19..8e05ecf 100644 --- a/README.md +++ b/README.md @@ -366,8 +366,8 @@ below. Nothing is installed without someone clicking. the live item alone with the gradient filling the tile edge to edge — a wall panel that says one thing. Admin config: ProPresenter endpoint (host : port), password. - Tile settings: a **Show** group of switches for the playlist name, live - status, NOW and NEXT labels, slide count, progress bar, the next list and + Tile settings: a **Show** group of switches for the playlist name, audience + look, NOW and NEXT labels, slide count, progress bar, the next list and its item tags, plus tidy titles. - **Timers** — live ProPresenter timers (countdown, countdown-to-time, elapsed), the **video countdown** (time left on what ProPresenter is diff --git a/modules/propresenter-now-next/client.js b/modules/propresenter-now-next/client.js index f168a88..ab593e8 100644 --- a/modules/propresenter-now-next/client.js +++ b/modules/propresenter-now-next/client.js @@ -212,7 +212,8 @@ export default function create({ root, moduleApi }) { } function renderLive() { - liveEl.classList.remove('is-live', 'is-down'); + liveEl.classList.remove('is-live', 'is-down', 'is-look', 'is-modified'); + liveEl.removeAttribute('title'); if (feedOffline) { liveEl.classList.add('is-down'); liveTextEl.textContent = 'Feed offline'; @@ -231,6 +232,18 @@ export default function create({ root, moduleApi }) { } if (state.reachable) { liveEl.classList.add('is-live'); + const lookName = state.look && state.look.name; + if (lookName) { + // The audience look replaces "Live". textContent, never innerHTML: + // the name is whatever someone typed into ProPresenter. + const modified = Boolean(state.look.modified); + liveEl.classList.add('is-look'); + liveEl.classList.toggle('is-modified', modified); + liveEl.title = modified ? 'Audience look (modified)' : 'Audience look'; + liveTextEl.textContent = lookName; + moduleApi.setStatus('ok', `Live · ${lookName}`); + return; + } liveTextEl.textContent = 'Live'; moduleApi.setStatus('ok', 'Following ProPresenter'); return; diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 2b02e79..8a5f92c 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-now-next", "name": "ProPresenter", - "version": "1.1.1", + "version": "1.2.0", "proddash": ">=1.5.0", "description": "What is live in ProPresenter and what comes next", "permissions": [ @@ -50,7 +50,7 @@ }, "showLive": { "type": "switch", - "label": "Live status", + "label": "Audience look", "default": true, "group": "Show" }, diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js index bf7048a..31ccee7 100644 --- a/modules/propresenter-now-next/server.js +++ b/modules/propresenter-now-next/server.js @@ -10,6 +10,12 @@ * * GET /state → { state: } (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'); @@ -17,6 +23,17 @@ const { createProPresenterClient, clearedViewModel } = require('./propresenter-c /** 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; @@ -31,30 +48,38 @@ module.exports = { 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; - 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); - } - } + broadcast(); }, }); @@ -65,8 +90,122 @@ module.exports = { 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'); @@ -83,10 +222,12 @@ module.exports = { }, HEARTBEAT_MS); heartbeat.unref?.(); - current = { streams, getLatest: () => latest }; + current = { streams, getLatest: snapshot }; return { stop() { + lookStopped = true; + clearTimeout(lookTimer); clearInterval(heartbeat); client.stop(); for (const res of streams) { @@ -135,7 +276,7 @@ module.exports = { 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' }, + 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' }, @@ -149,7 +290,7 @@ module.exports = { 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() })); + res.end(JSON.stringify({ state: current ? current.getLatest() : { ...clearedViewModel(), look: null } })); }, 'GET /stream': (req, res) => { @@ -160,7 +301,7 @@ module.exports = { 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); - const view = current ? current.getLatest() : clearedViewModel(); + 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; diff --git a/modules/propresenter-now-next/style.css b/modules/propresenter-now-next/style.css index 3962d09..2acee48 100644 --- a/modules/propresenter-now-next/style.css +++ b/modules/propresenter-now-next/style.css @@ -141,6 +141,24 @@ transition: opacity 0.6s ease 1.4s; } +/* The audience look is information, not a status chip: it stays, in the + name as typed, in the normal text colour. A look changed by hand (matching + no saved look) is set in italic. */ +[data-module="propresenter-now-next"] .nn-live.is-live.is-look .nn-live-text { + opacity: 1; + transition: none; + max-width: 14em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + color: var(--text); + font-weight: 600; +} +[data-module="propresenter-now-next"] .nn-live.is-look.is-modified .nn-live-text { + font-style: italic; +} + /* ── NOW card ── FIXED height so the title box is deterministic and the NOW/NEXT layout cannot oscillate (see the wall display's notes). */