diff --git a/README.md b/README.md index c65f2dd..dbbcd16 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-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`. 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 = ''; + }, + }; +} 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 + } + } +} 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)); + }, + }; + }, +}; 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; } +} 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}`); +}); 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). */