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). */