From 7c0579cbf970bf0c3b37e966646c21260c566fc5 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:36 -0400 Subject: [PATCH 1/6] 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 + } + } +} -- 2.54.0 From 3bfea6010a60a7693ef7fc9ab3ad4a851d90b772 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:40 -0400 Subject: [PATCH 2/6] 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)); + }, + }; + }, +}; -- 2.54.0 From 09f59c1bef394f7ff082b5986b1b85db1d7c5935 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:53 -0400 Subject: [PATCH 3/6] 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 = ''; + }, + }; +} -- 2.54.0 From cd13ecaf7c7ad729ec42bd4589005195a7a7077b Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:12 -0400 Subject: [PATCH 4/6] 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; } +} -- 2.54.0 From 70f56c7909c5a3155397fa09652d917f01645816 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:25 -0400 Subject: [PATCH 5/6] 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`. -- 2.54.0 From 49f8a82fbd39bbced3bb478aefd7c20ff26403ca Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:39 -0400 Subject: [PATCH 6/6] 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}`); +}); -- 2.54.0