diff --git a/modules/propresenter-now-next/client.js b/modules/propresenter-now-next/client.js new file mode 100644 index 0000000..624cca9 --- /dev/null +++ b/modules/propresenter-now-next/client.js @@ -0,0 +1,474 @@ +/* ProPresenter Now / Next — client part. + + Ported from band-lineup-display/propresenter-app/public/app.js into the + ProdDash module contract: the server part does all the ProPresenter + talking and streams the view-model here over SSE. Connection setup lives + in /admin; the tile only renders. */ + +export default function create({ root, moduleApi }) { + let state = null; + let feedOffline = false; + let stream = null; + let resizeObserver = null; + + /* ── DOM skeleton ───────────────────────────────────────────────── */ + + root.innerHTML = ` +
+
+
+
Connecting…
+
+
+
Now
+
Connecting…
+
+ +
+
+ +
+ +
+
`; + + const screenEl = root.querySelector('.nn-screen'); + const topRow = root.querySelector('.nn-top'); + const playlistNameEl = root.querySelector('.nn-playlist'); + const liveEl = root.querySelector('.nn-live'); + const liveTextEl = root.querySelector('.nn-live-text'); + const nowEl = root.querySelector('.nn-now'); + const nowTitleBox = root.querySelector('.nn-title-box'); + let nowTitleEl = root.querySelector('.nn-title'); + const nowMetaEl = root.querySelector('.nn-meta'); + const progressEl = root.querySelector('.nn-progress'); + const progressFillEl = root.querySelector('.nn-progress-fill'); + const nextLabelEl = root.querySelector('.nn-next-label'); + const nextListEl = root.querySelector('.nn-next-list'); + const nextMoreEl = root.querySelector('.nn-next-more'); + + function prefs() { + return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles + } + + /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ + + function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) + } + + function displayName(raw) { + return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); + } + + function itemTypeTag(type) { + switch (type) { + case 'media': return 'Media'; + case 'audio': return 'Audio'; + case 'livevideo': return 'Live'; + case 'placeholder': return 'PCO'; + default: return ''; + } + } + + /* ── rendering ──────────────────────────────────────────────────── */ + + /** The items after the live one, hidden entries dropped. */ + function upcomingItems() { + const items = Array.isArray(state?.items) ? state.items : []; + // Presenting from the playlist → everything after the live item. + // Presenting from the Library (activeIndex < 0) → the whole list is still ahead. + const from = state && state.activeIndex >= 0 ? state.activeIndex + 1 : 0; + const out = []; + for (let i = from; i < items.length; i += 1) { + if (!items[i].isHidden) out.push(items[i]); + } + return out; + } + + /** + * Hand the browser a brand-new title element whenever the text changes — + * on some GPUs a large text element keeps a stale compositor backing + * store, ghosting the previous song under the new one. A fresh node has + * no old backing store. (Carried over from the wall display.) + */ + let lastTitleText = null; + function setNowTitle(text) { + if (text === lastTitleText && nowTitleEl.isConnected) return; + lastTitleText = text; + const fresh = document.createElement('div'); + fresh.className = 'nn-title'; + fresh.textContent = text; + nowTitleEl.replaceWith(fresh); + nowTitleEl = fresh; + } + + /** + * Size the NOW title as large as fits without ever breaking a word: + * with word-breaking forbidden, an over-wide word overflows and the same + * fit test that guards height rejects that size too; binary search lands + * on the biggest size where whole words fit. Hysteresis stops ±1px flap. + */ + function fitNowTitle() { + if (nowTitleEl.classList.contains('is-waiting')) return; + const boxHeight = nowTitleBox.clientHeight; + const boxWidth = nowTitleBox.clientWidth; + const MIN = 11; + + nowTitleEl.style.whiteSpace = 'normal'; + nowTitleEl.style.overflowWrap = 'normal'; + nowTitleEl.style.wordBreak = 'normal'; + nowTitleEl.style.hyphens = 'none'; + + if (boxHeight < MIN || !boxWidth) { + nowTitleEl.style.fontSize = `${MIN}px`; + return; + } + + const fits = (size) => { + nowTitleEl.style.fontSize = `${size}px`; + return nowTitleEl.scrollWidth <= nowTitleBox.clientWidth + 1 + && nowTitleEl.scrollHeight <= nowTitleBox.clientHeight + 1; + }; + + const max = Math.max(MIN, Math.floor(boxHeight)); + let best = MIN; + if (fits(max)) { + best = max; + } else { + let lo = MIN; + let hi = max; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + } + const currentSize = parseFloat(nowTitleEl.style.fontSize) || 0; + // Hold the current size only if it still genuinely fits (a plain ±1px + // band can straddle the line-wrap threshold and stick on the + // overflowing side). + if (currentSize && Math.abs(currentSize - best) <= 1 && fits(currentSize)) { + nowTitleEl.style.fontSize = `${currentSize}px`; + return; + } + nowTitleEl.style.fontSize = `${best}px`; + + // Last resort: one word so long it can't fit even at MIN — allow it to + // break rather than overflow the card. + if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) { + nowTitleEl.style.overflowWrap = 'anywhere'; + nowTitleEl.style.wordBreak = 'break-word'; + } + } + + /** + * Hide the NEXT rows past the bottom of the list and count them into + * "+ N more". Rows are hidden, never removed, so the list's height stays + * fixed and the layout can't ratchet. + */ + function markNextOverflow() { + const rows = Array.from(nextListEl.children); + for (const row of rows) row.style.visibility = ''; + let hidden = 0; + for (let pass = 0; pass < 3; pass += 1) { + const listRect = nextListEl.getBoundingClientRect(); + if (listRect.height <= 0) return; + let changed = 0; + for (const row of rows) { + if (row.style.visibility === 'hidden') continue; + if (row.getBoundingClientRect().bottom > listRect.bottom + 1) { + row.style.visibility = 'hidden'; + changed += 1; + } + } + hidden += changed; + if (hidden > 0) { + nextMoreEl.hidden = false; + nextMoreEl.textContent = `+ ${hidden} more`; + } + if (changed === 0) break; + } + if (hidden === 0) { + nextMoreEl.hidden = true; + nextMoreEl.textContent = ''; + } + } + + function renderLive() { + liveEl.classList.remove('is-live', 'is-down'); + if (feedOffline) { + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Feed offline'; + moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + return; + } + if (!state) { + liveTextEl.textContent = 'Connecting…'; + moduleApi.setStatus('connecting', 'Connecting…'); + return; + } + if (!state.enabled) { + liveTextEl.textContent = 'Not set up'; + moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + return; + } + if (state.reachable) { + liveEl.classList.add('is-live'); + liveTextEl.textContent = 'Live'; + moduleApi.setStatus('ok', 'Following ProPresenter'); + return; + } + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Reconnecting…'; + moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…'); + } + + function renderNow() { + const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const rawName = activeItem?.name || state?.activePresentationName || ''; + + for (const stale of nowEl.querySelectorAll('.nn-hint, .nn-stale')) stale.remove(); + + if (feedOffline) { + setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Feed offline'); + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + const staleEl = document.createElement('div'); + staleEl.className = 'nn-stale'; + staleEl.textContent = 'Not receiving updates from the ProdDash server.'; + nowEl.appendChild(staleEl); + return; + } + + if (!rawName) { + // A status message is not a song title — keep its CSS size instead of + // the fill-the-box fit. + let hint = ''; + if (!state?.enabled) { + setNowTitle('No ProPresenter connection'); + hint = 'Set the ProPresenter host and port in Admin.'; + } else { + // Connected with nothing live is normal (output cleared) — say it + // plainly so nobody goes hunting for a broken connection. + setNowTitle(state?.reachable ? 'Nothing on screen' : 'Reconnecting…'); + hint = state?.reachable + ? 'Connected to ProPresenter — waiting for a slide to go live.' + : 'Trying to reach ProPresenter.'; + } + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + if (hint) { + const hintEl = document.createElement('div'); + hintEl.className = 'nn-hint'; + hintEl.textContent = hint; + nowEl.appendChild(hintEl); + } + return; + } + + setNowTitle(displayName(rawName)); + nowTitleEl.classList.remove('is-waiting'); + fitNowTitle(); + // Re-fit after layout settles: on the first render the box may not have + // its final height, and a too-tall fit would overflow for a frame. + requestAnimationFrame(() => { + if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle(); + }); + + // An empty NEXT list looks broken unless the tile says why it is empty. + if (!upcomingItems().length) { + const kind = state?.playlistKind; + let why = ''; + if (kind === 'group' || kind === 'none') { + why = 'Presenting from the library — no playlist open.'; + } else if (kind === 'unknown') { + why = 'ProPresenter did not return this playlist’s items.'; + } else if (Array.isArray(state?.items) && state.items.length) { + why = 'Last item in the playlist.'; + } + if (why) { + const note = document.createElement('div'); + note.className = 'nn-hint'; + note.textContent = why; + nowEl.appendChild(note); + } + } + + const total = state.currentSlideTotal || activeItem?.slideCount || 0; + const current = state.currentSlide || 0; + if (total > 0 && current > 0) { + const remaining = Math.max(0, total - current); + nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; + } else if (total > 0) { + nowMetaEl.textContent = `${total} slides`; + } else if (current > 0) { + nowMetaEl.textContent = `Slide ${current}`; + } else { + nowMetaEl.textContent = ''; + } + + if (prefs().showProgress && total > 0) { + progressEl.hidden = false; + const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; + progressFillEl.style.width = `${pct}%`; + } else { + progressEl.hidden = true; + } + } + + function renderNext() { + nextListEl.innerHTML = ''; + const upcoming = upcomingItems(); + screenEl.classList.toggle('no-upcoming', upcoming.length === 0); + + for (const item of upcoming) { + const row = document.createElement('div'); + const nameEl = document.createElement('div'); + nameEl.className = 'nn-next-name'; + + if (item.type === 'header') { + row.className = 'nn-next-item is-header'; + nameEl.textContent = displayName(item.name || ''); + row.appendChild(nameEl); + nextListEl.appendChild(row); + continue; + } + + row.className = 'nn-next-item'; + nameEl.textContent = displayName(item.name) || '(untitled)'; + row.appendChild(nameEl); + + if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { + const chip = document.createElement('div'); + chip.className = 'nn-count-chip'; + chip.textContent = String(item.slideCount); + row.appendChild(chip); + } else { + const tag = itemTypeTag(item.type); + if (tag) { + const tagEl = document.createElement('div'); + tagEl.className = 'nn-type-tag'; + tagEl.textContent = tag; + row.appendChild(tagEl); + } + } + nextListEl.appendChild(row); + } + + nextLabelEl.hidden = upcoming.length === 0; + nextMoreEl.hidden = true; + markNextOverflow(); + requestAnimationFrame(markNextOverflow); + } + + function render() { + screenEl.classList.toggle('is-offline', feedOffline); + const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; + topRow.classList.toggle('hidden-row', !showTop); + playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; + renderLive(); + renderNow(); + renderNext(); + } + + /* ── sizing: --unit scales the whole card to the tile ───────────── */ + + function applyUnit() { + const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); + screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + feedOffline = false; + render(); + }, + error() { + // EventSource retries by itself (and the shell recreates closed + // streams), but the tile must admit it is frozen meanwhile. + feedOffline = true; + render(); + }, + events: { + state(ev) { + try { + state = JSON.parse(ev.data); + } catch { + return; + } + feedOffline = false; + render(); + }, + }, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + applyUnit(); + moduleApi.setStatus('connecting', 'Connecting…'); + // Instant paint from the snapshot, then live updates. + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (body && body.state && !state) { + state = body.state; + render(); + } + }) + .catch(() => { /* the stream will cover it */ }); + connect(); + + // Re-fit on any size change; guard against reacting to our own DOM + // writes by only re-rendering when a watched box actually changed. + let lastW = 0; + let lastH = 0; + let pending = false; + resizeObserver = new ResizeObserver(() => { + const w = Math.round(root.clientWidth); + const h = Math.round(root.clientHeight); + if (w === lastW && h === lastH) return; + lastW = w; + lastH = h; + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + applyUnit(); + fitNowTitle(); + markNextOverflow(); + }); + }); + resizeObserver.observe(root); + render(); + }, + stop() { + stream?.close(); + stream = null; + resizeObserver?.disconnect(); + resizeObserver = null; + root.innerHTML = ''; + }, + onResize() { + applyUnit(); + fitNowTitle(); + markNextOverflow(); + }, + }; +} diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json new file mode 100644 index 0000000..eb3feae --- /dev/null +++ b/modules/propresenter-now-next/module.json @@ -0,0 +1,33 @@ +{ + "id": "propresenter-now-next", + "name": "ProPresenter Now / Next", + "version": "1.0.0", + "description": "What is live in ProPresenter and what comes next", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 3, "h": 3 }, + "defaultSize": { "w": 4, "h": 5 }, + "configSchema": { + "host": { + "type": "string", + "label": "ProPresenter host / IP", + "default": "" + }, + "port": { + "type": "number", + "label": "ProPresenter network port", + "default": 1025 + }, + "password": { + "type": "password", + "label": "API password (only if set in ProPresenter)", + "default": "" + } + }, + "instanceSchema": { + "showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, + "showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, + "cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } + } +} diff --git a/modules/propresenter-now-next/propresenter-core/index.js b/modules/propresenter-now-next/propresenter-core/index.js new file mode 100644 index 0000000..fb72fb4 --- /dev/null +++ b/modules/propresenter-now-next/propresenter-core/index.js @@ -0,0 +1,1029 @@ +'use strict'; + +/* ------------------------------------------------------------------ * + * ProPresenter 7 / 20 integration core * + * * + * Shared by two programs: * + * 1. the Band Lineup Electron app (main.js, via IPC to its renderer) * + * 2. propresenter-app — the standalone Now/Next display for studios * + * * + * Everything here is plain Node: global fetch only, no Electron, no * + * npm dependencies. All ProPresenter HTTP lives in this module so no * + * browser ever has to deal with CORS or a CSP exception. We poll the * + * basic REST endpoints (works on every ProPresenter 7+ version) and * + * hand callers a small, stable view-model. * + * ------------------------------------------------------------------ */ + +/** Default ProPresenter network API port (user-configurable in ProPresenter ▸ Preferences ▸ Network). */ +const DEFAULT_PP_PORT = 1025; +/** How often to poll ProPresenter for playlist/slide changes while connected. */ +const DEFAULT_POLL_MS = 600; +/** Slower retry cadence when ProPresenter is unreachable. */ +const DEFAULT_RETRY_MS = 2500; + +/** Normalize a stored/entered connection config into the shape the client expects. */ +function normalizeConfig(entry) { + const host = String(entry?.host || '').trim(); + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + return { + enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port), + host, + port, + playlistUuid: String(entry?.playlistUuid || ''), + playlistName: String(entry?.playlistName || ''), + }; +} + +/** The internal polling state, before it is projected into a view-model. */ +function emptyPollState() { + return { + reachable: false, + lastError: '', + playlistUuid: '', + playlistName: '', + items: [], + activeUuid: '', + activeName: '', + activeIndex: -1, + currentSlide: 0, + livePresentationUuid: '', + livePresentationSlideTotal: 0, + /** 'playlist' | 'group' | 'unknown' | 'none' — what ProPresenter pointed us at. */ + playlistKind: 'none', + itemsRetryAt: 0, + /** From /v1/status/layers: is the slide layer actually on screen right now? */ + slideLayerLive: false, + }; +} + +/** + * The "nothing connected" view-model. Consumers render this after a disconnect so + * the display clears instead of freezing on the last live slide. + */ +function clearedViewModel() { + return { + enabled: false, + reachable: false, + lastError: '', + needsPlaylist: false, + playlistName: '', + items: [], + activeIndex: -1, + activePresentationName: '', + currentSlide: 0, + currentSlideTotal: 0, + }; +} + +/** Flatten the /v1/playlists tree (groups → playlists) into a flat list a picker can show. */ +function flattenPlaylists(nodes, groupName, out) { + for (const node of Array.isArray(nodes) ? nodes : []) { + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const name = String(id.name || node?.name || ''); + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist' || (!fieldType && uuid && !Array.isArray(node?.children))) { + if (uuid) out.push({ uuid, name, group: groupName }); + } + if (Array.isArray(node?.children) && node.children.length) { + flattenPlaylists(node.children, fieldType === 'group' ? name : groupName, out); + } + } + return out; +} + +/** Parse a /v1/playlist/{uuid} response into our item shape, tolerating ProPresenter version differences. */ +function parsePlaylistItems(data) { + const list = Array.isArray(data?.items) + ? data.items + : Array.isArray(data?.children) + ? data.children + : Array.isArray(data) + ? data + : []; + return list.map((item) => { + const id = item?.id || {}; + return { + uuid: String(id.uuid || item?.uuid || ''), + name: String(id.name || item?.name || ''), + type: String(item?.type || item?.field_type || 'presentation'), + isHidden: Boolean(item?.is_hidden ?? item?.isHidden), + isPco: Boolean(item?.is_pco ?? item?.isPco), + slideCount: null, + }; + }); +} + +/** Depth-first search the /v1/playlists tree for a playlist by uuid; returns its index path + name. */ +function findPlaylistPath(nodes, targetUuid, prefix) { + if (!Array.isArray(nodes)) return null; + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + if (uuid && uuid === targetUuid) { + return { + path: here, + name: String(id.name || node?.name || ''), + fieldType: String(node?.field_type || node?.type || ''), + }; + } + const deeper = findPlaylistPath(node?.children, targetUuid, here); + if (deeper) return deeper; + } + return null; +} + +function countSlides(presentationData) { + // PP20 wraps details under `presentation`; older shapes are flat. + const presentation = presentationData?.presentation || presentationData; + const groups = Array.isArray(presentation?.groups) ? presentation.groups : []; + let count = 0; + for (const group of groups) { + const slides = Array.isArray(group?.slides) ? group.slides : []; + // Count ALL slides including disabled — slide_index.index counts disabled slides too, + // so the total must match that same sequence or "Slide X / Y" will be inconsistent. + count += slides.length; + } + return count; +} + +/** + * Tidy an item name for display only (the raw name is kept for ProPresenter matching). + * Strips arrangement/section tags in brackets ([ Full ], ( TAG ), { ... }), a leading date + * code (e.g. "260607 "), separator dashes, and any leftover edge punctuation/whitespace so the + * card reads as cleanly as possible — "260607 Taking Thoughts Captive - [ Full ]" → "Taking Thoughts Captive". + * + * Exported for Node-side consumers; the browser UIs keep their own copy of this pure + * function because they load as plain scripts with no module loader. + */ +function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) +} + +/** + * Create a ProPresenter polling client. + * + * @param {object} [options] + * @param {(view: object) => void} [options.onData] Called with a view-model on every poll tick. + * @param {number} [options.pollMs] Poll cadence while reachable. + * @param {number} [options.retryMs] Poll cadence while unreachable. + */ +function createProPresenterClient(options = {}) { + const pollMs = Number.isFinite(options.pollMs) && options.pollMs > 0 ? options.pollMs : DEFAULT_POLL_MS; + const retryMs = Number.isFinite(options.retryMs) && options.retryMs > 0 ? options.retryMs : DEFAULT_RETRY_MS; + let onData = typeof options.onData === 'function' ? options.onData : null; + + /** @type {{ enabled: boolean, host: string, port: number, password: string, playlistUuid: string, playlistName: string }} */ + let config = { enabled: false, host: '', port: 0, password: '', playlistUuid: '', playlistName: '' }; + let state = emptyPollState(); + let pollTimer = null; + let stopped = true; + /** Monotonic token so a superseded poll loop can detect it should bail. */ + let runToken = 0; + /** uuid -> master (document) slide count. Counts are stable during a service, so fetch once and reuse. */ + const slideCountCache = new Map(); + /** + * uuid -> arrangement-expanded cue count (the total that matches slide_index.index). + * slide_index counts the SELECTED arrangement's expanded cue sequence (repeated groups count + * multiple times), while /v1/presentation/{uuid} only returns the unique master groups — so the + * two disagree whenever an arrangement repeats a section. We recover the true total by probing the + * thumbnail endpoint, whose index "respects the selected arrangement" (same space as slide_index). + */ + const arrangementCountCache = new Map(); + /** Cached URL that successfully returned the current playlist's item list (PP20 renamed this route). */ + let itemsSourceUrl = ''; + let itemsForUuid = ''; + /** Index path of the playlist we last proved contains the live presentation. */ + let provenPlaylistPath = ''; + /** Name of that proven playlist, so the header doesn't blank when the hint disagrees. */ + let provenPlaylistName = ''; + /** Last playlist uuid ProPresenter HINTED at, for change detection. */ + let lastHintUuid = ''; + /** Poll ticks since the last tree-wide search, so it cannot run every 600ms. */ + let searchCooldown = 0; + /** Monotonic tick count, so the heavy calls run on a slow secondary cadence. */ + let tick = 0; + /** True when the last slide_index came back null. */ + let lastIndexWasNull = false; + + function url(target) { + const built = new URL(`http://${config.host}:${config.port}${target}`); + if (config.password) built.searchParams.set('password', config.password); + return built; + } + + async function requestJson(target, init) { + const response = await fetch(url(target), { + headers: { Accept: 'application/json' }, + ...init, + }); + if (!response.ok) { + throw new Error(`ProPresenter ${response.status} ${response.statusText}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async function slideCount(uuid) { + if (!uuid) return null; + if (slideCountCache.has(uuid)) return slideCountCache.get(uuid); + try { + const data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + const count = countSlides(data); + slideCountCache.set(uuid, count); + return count; + } catch { + return null; + } + } + + /** + * Probe the thumbnail endpoint to find how many cues the SELECTED arrangement actually plays. + * The thumbnail index "respects the selected arrangement of cues" — the exact same index space + * slide_index reports — and returns 404 once the index runs past the end. So the count of cues is + * the smallest index that 404s. We only read the HTTP status (the JPEG body is cancelled), and do + * an exponential-then-binary search so the whole probe is ~2·log2(N) tiny requests. + * Returns the cue count, or null if the presentation can't be probed (auth error / unexpected status). + */ + async function probeArrangementLength(uuid) { + const CAP = 4000; // guard against a build that clamps instead of 404-ing — fall back rather than loop + const exists = async (index) => { + try { + const res = await fetch(url(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${index}`), { + headers: { Accept: 'image/jpeg' }, + }); + try { await res.body?.cancel?.(); } catch { /* body already consumed/closed */ } + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; // 403 or anything unexpected → can't trust the probe + } catch { + return null; + } + }; + + const first = await exists(0); + if (first === null) return null; + if (first === false) return 0; + + // Exponential search for an index that is past the end (404). + let lo = 0; // known valid + let hi = 1; // candidate; grows until invalid + while (true) { + const r = await exists(hi); + if (r === null) return null; + if (r === false) break; + lo = hi; + hi *= 2; + if (hi > CAP) return null; + } + // Binary search the boundary: lo valid, hi invalid. + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + const r = await exists(mid); + if (r === null) return null; + if (r) lo = mid; else hi = mid; + } + return lo + 1; // cue indices are 0-based, so the count is the last valid index + 1 + } + + /** + * The slide TOTAL for the live presentation, in the same index space as slide_index.currentSlide. + * Prefers the arrangement-expanded count (cached per uuid); falls back to the master slide count if + * the thumbnail probe is unavailable. Always returns a value >= the live slide so "Slide X / Y" can + * never show X > Y (a stale arrangement is re-probed when the live index outruns the cached total). + */ + /** Pull the arrangement tag out of a playlist item name: "AMAZING! - [ Full ]" -> "full". */ + function arrangementTagFromName(name) { + const m = String(name || '').match(/[[({]\s*([^\])}]+?)\s*[\])}]/); + return m ? m[1].trim().toLowerCase() : ''; + } + + /** + * The true slide total, in the SAME index space as slide_index — computed from the + * presentation's arrangement structure rather than guessed. + * + * ProPresenter reports \`groups\` (each with slides), \`arrangements\` (each a flat ordered + * list of group UUIDs, which REPEAT sections like choruses), and \`current_arrangement\`. + * slide_index counts the selected arrangement's expanded sequence, so the correct total is + * the sum of slide counts over that arrangement's group list. The old thumbnail probe + * returned the MASTER count on this build (49 vs the real 64 for a [Full] arrangement), + * which made the live slide overshoot the total and the counter read "63/63, 64/64…". + * + * @param arrangementHint arrangement name from the playlist item, used when + * current_arrangement is blank (this PP build often leaves it blank). + */ + async function computePresentationTotal(uuid, arrangementHint) { + let data; + try { + data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + } catch { + return null; + } + const pres = data?.presentation || data; + const groups = Array.isArray(pres?.groups) ? pres.groups : []; + const groupSlides = new Map(); + let master = 0; + for (const g of groups) { + const n = Array.isArray(g?.slides) ? g.slides.length : 0; + master += n; + const gu = String(g?.uuid || g?.id?.uuid || ''); + if (gu) groupSlides.set(gu, n); + } + const arrangements = Array.isArray(pres?.arrangements) ? pres.arrangements : []; + if (!arrangements.length) return master || null; + + const currentUuid = String(pres?.current_arrangement || ''); + let chosen = currentUuid + ? arrangements.find((a) => String(a?.id?.uuid || '') === currentUuid) + : null; + // current_arrangement is frequently blank on PP20 — fall back to the item's "[ tag ]". + if (!chosen && arrangementHint) { + chosen = arrangements.find((a) => String(a?.id?.name || '').trim().toLowerCase() === arrangementHint); + } + if (!chosen && arrangements.length === 1) chosen = arrangements[0]; + if (!chosen) return master || null; + + const refs = Array.isArray(chosen.groups) ? chosen.groups : []; + let total = 0; + for (const ref of refs) { + const gu = typeof ref === 'string' ? ref : String(ref?.uuid || ref?.id?.uuid || ''); + total += groupSlides.get(gu) || 0; + } + return total > 0 ? total : (master || null); + } + + async function liveSlideTotal(uuid, currentSlide, arrangementHint) { + if (!uuid) return 0; + if (!arrangementCountCache.has(uuid)) { + const computed = await computePresentationTotal(uuid, arrangementHint); + if (computed && computed > 0) { + arrangementCountCache.set(uuid, computed); + } else { + // Last resort only if the structure was unreadable: the old probe, then master. + const probed = await probeArrangementLength(uuid); + const master = await slideCount(uuid); + arrangementCountCache.set(uuid, (probed && probed > 0) ? probed : (master || 0)); + } + } + const total = arrangementCountCache.get(uuid); + // If the live slide somehow exceeds the computed total, re-derive ONCE (an arrangement + // switch mid-song). Never poison the cache by setting total = currentSlide — that is + // exactly what produced the ever-climbing "X / X". + if (currentSlide > total) { + const recomputed = await computePresentationTotal(uuid, arrangementHint); + if (recomputed && recomputed >= currentSlide) { + arrangementCountCache.set(uuid, recomputed); + return recomputed; + } + // Can't resolve a bigger real total — show the honest live index rather than X/(smaller). + return currentSlide; + } + return total; + } + + /** + * How confident are we that this playlist item is the presentation that is live? + * 3 = same uuid, 2 = same title, 1 = one title is a prefix of the other, 0 = no. + * + * Compared on TIDIED names, because ProPresenter reports the presentation as + * "Covered By The Blood" while the playlist item carries the arrangement, + * "Covered By The Blood - [ Full ]". + * + * Deliberately NOT a substring test. A loose "does either contain the other" matched a + * pre-service playlist containing a song called "The Blood" and pointed the whole display + * at the wrong running order — a plain substring is far too eager on a song library. + */ + function matchStrength(item, liveName, liveUuid) { + if (liveUuid && item.uuid && item.uuid === liveUuid) return 3; + const itemName = cleanItemName(item.name || '').toLowerCase(); + const live = cleanItemName(liveName || '').toLowerCase(); + if (!itemName || !live) return 0; + if (itemName === live) return 2; + // A prefix relationship covers "… TAG" and "… (Reprise)" style siblings. Require a + // meaningful stem so short titles can't latch onto anything. + const stem = Math.min(itemName.length, live.length); + if (stem >= 6 && (itemName.startsWith(live) || live.startsWith(itemName))) return 1; + return 0; + } + + function itemMatchesLive(item, liveName, liveUuid) { + return matchStrength(item, liveName, liveUuid) > 0; + } + + /** Every playlist in the tree, with the index path that /v1/playlist/{a}/{b} wants. */ + async function listAllPlaylists() { + const out = []; + let tree; + try { + tree = await requestJson('/v1/playlists'); + } catch { + return out; + } + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const walk = (ns, prefix) => { + for (let i = 0; i < (Array.isArray(ns) ? ns.length : 0); i += 1) { + const node = ns[i]; + const id = node?.id || {}; + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist') { + out.push({ path: here.join('/'), name: String(id.name || ''), uuid: String(id.uuid || '') }); + } + walk(node?.children, here); + } + }; + walk(nodes, []); + return out; + } + + /** + * Find the playlist that actually contains what is on screen. + * + * Necessary because /v1/playlist/active reports null and /v1/playlist/focused reports + * whatever the operator last clicked — which is frequently a GROUP, not the running order. + * Trusting it meant the display showed no upcoming items all through a service that was + * being run from a playlist the whole time. Searching by the live presentation is the only + * signal that reflects what is genuinely being presented. + */ + async function findPlaylistContainingLive(liveName, liveUuid) { + const playlists = await listAllPlaylists(); + if (!playlists.length) return null; + // Check the one we already proved first: while a service runs, that is a single request. + playlists.sort((a, b) => (b.path === provenPlaylistPath ? 1 : 0) - (a.path === provenPlaylistPath ? 1 : 0)); + + let best = null; + for (const entry of playlists) { + const url = `/v1/playlist/${entry.path}`; + let items = []; + try { + items = parsePlaylistItems(await requestJson(url)); + } catch { + continue; + } + if (!items.length) continue; + let strength = 0; + for (const item of items) { + strength = Math.max(strength, matchStrength(item, liveName, liveUuid)); + } + if (!strength) continue; + if (!best || strength > best.strength) { + best = { items, name: entry.name, uuid: entry.uuid, path: entry.path, strength }; + } + // A uuid or exact-title hit is as good as it gets — stop walking the tree. + if (strength >= 2) break; + } + if (!best) return null; + provenPlaylistPath = best.path; + itemsSourceUrl = `/v1/playlist/${best.path}`; + itemsForUuid = best.uuid; + return best; + } + + function recomputeActiveIndex() { + // 1. Try UUID match (works when presentation UUID === playlist item UUID). + if (state.activeUuid) { + const byUuid = state.items.findIndex((item) => item.uuid && item.uuid === state.activeUuid); + if (byUuid >= 0) { state.activeIndex = byUuid; return; } + } + // 2. Fall back to name match. On PP20, slide_index gives the presentation document UUID + // which differs from the playlist item UUID, so UUID matching silently fails. + // Names always match because both the playlist item and the presentation share the same title. + if (state.activeName) { + const normalised = state.activeName.trim().toLowerCase(); + const byName = state.items.findIndex( + (item) => item.name && item.name.trim().toLowerCase() === normalised, + ); + if (byName >= 0) { state.activeIndex = byName; return; } + // Partial name match as a last resort (handles slight label differences). + // Strongest match wins, rather than the first loose one — inside a long song list + // several titles can look similar. + let bestIndex = -1; + let bestStrength = 0; + for (let i = 0; i < state.items.length; i += 1) { + const strength = matchStrength(state.items[i], state.activeName, state.livePresentationUuid); + if (strength > bestStrength) { + bestStrength = strength; + bestIndex = i; + } + } + state.activeIndex = bestIndex; + return; + } + state.activeIndex = -1; + } + + /** + * ProPresenter 20 renamed the "list a playlist's items" route and didn't document it publicly. + * Since this app runs on the same LAN as ProPresenter, we probe the plausible address formats + * at runtime and use whichever returns a real item list, caching the winner. + */ + async function discoverItems(playlistUuid) { + if (!playlistUuid) return { items: [], kind: 'none' }; + const enc = encodeURIComponent; + + // Reuse a previously-working URL for this playlist. + if (itemsSourceUrl && itemsForUuid === playlistUuid) { + try { + const items = parsePlaylistItems(await requestJson(itemsSourceUrl)); + if (items.length) return { items, kind: 'playlist' }; + } catch { + /* re-discover below */ + } + } + + let pathStr = []; + let name = ''; + let fieldType = ''; + try { + const tree = await requestJson('/v1/playlists'); + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const found = findPlaylistPath(nodes, playlistUuid, []); + if (found) { + pathStr = found.path; + name = found.name; + fieldType = found.fieldType; + } + } catch { + /* ignore */ + } + + // A GROUP holds playlists, not items, and ProPresenter's /v1/playlist/focused will + // happily name one — which is what happens when the operator is presenting from the + // Library rather than from a service playlist. Listing its items correctly returns + // nothing, so say so instead of leaving the caller to guess from an empty array. + if (fieldType === 'group') { + return { items: [], kind: 'group' }; + } + + const candidates = [`/v1/playlist/${enc(playlistUuid)}`]; + if (name) candidates.push(`/v1/playlist/${enc(name)}`); + if (pathStr.length) { + candidates.push( + `/v1/playlist/${pathStr.join('.')}`, + `/v1/playlist/${pathStr.join(':')}`, + `/v1/playlist/${pathStr.join('-')}`, + `/v1/playlist/${pathStr.join('_')}`, + `/v1/playlist/${pathStr.join(',')}`, + `/v1/playlist/${enc(pathStr.join('/'))}`, + `/v1/playlist/${pathStr[pathStr.length - 1]}`, + ); + } + + for (const candidate of candidates) { + try { + const items = parsePlaylistItems(await requestJson(candidate)); + if (items.length) { + itemsSourceUrl = candidate; + itemsForUuid = playlistUuid; + return { items, kind: 'playlist' }; + } + } catch { + /* try next candidate */ + } + } + return { items: [], kind: 'unknown' }; + } + + /** + * Refresh which playlist we're in (for the item list). + * Uses /v1/playlist/active then /v1/playlist/focused as fallback. + * Does NOT set the active item — that comes from slide_index (see applySlideIndex). + */ + async function refreshPlaylistContext() { + let playlistUuid = ''; + let playlistName = ''; + + try { + const active = await requestJson('/v1/playlist/active'); + const pl = active?.presentation?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + + if (!playlistUuid) { + try { + const focused = await requestJson('/v1/playlist/focused'); + const pl = focused?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + } + + // Compare against the previous HINT, not against the playlist we settled on. Otherwise + // every poll looks like a change (the hint keeps naming a group while we hold the real + // playlist) and re-probes ProPresenter twice a second for nothing. + const changed = playlistUuid !== lastHintUuid; + lastHintUuid = playlistUuid; + + if (playlistUuid && changed) { + state.playlistUuid = playlistUuid; + const found = await discoverItems(playlistUuid); + state.playlistKind = found.kind; + state.itemsRetryAt = 0; + // Only REPLACE the list when we actually resolved one. During a rehearsal the operator + // clicks around groups and library items, and ProPresenter reports each of those as the + // focused "playlist"; overwriting with an empty list each time is what made the running + // order keep vanishing off the screens. + if (found.items.length) { + state.items = found.items; + await fillSlideCounts(); + } + } else if (playlistUuid && !state.items.length && state.playlistKind !== 'group') { + // Retry a discovery that came back empty. Item lookup depends on probing undocumented + // routes, so a single failure used to be sticky for the whole service — the list would + // stay blank until the operator happened to switch playlists. + state.itemsRetryAt = (state.itemsRetryAt || 0) + 1; + if (state.itemsRetryAt >= 20) { + state.itemsRetryAt = 0; + const found = await discoverItems(playlistUuid); + state.items = found.items; + state.playlistKind = found.kind; + await fillSlideCounts(); + } + } else if (!playlistUuid) { + state.playlistUuid = ''; + state.items = []; + state.playlistKind = 'none'; + } + + // Don't present a group's name as if it were a service playlist — "EVENTS" reads like a + // running order when it is really just the folder the operator has open. + state.playlistName = state.playlistKind === 'group' ? '' : playlistName; + + // The hint above is unreliable. What matters is whether the list we are holding actually + // contains what is on screen; if it doesn't, go and find the playlist that does. + const liveName = state.activeName; + if (!liveName) return; + const holdsLive = state.items.some((item) => itemMatchesLive(item, liveName, state.livePresentationUuid)); + if (holdsLive) { + searchCooldown = 0; + // Keep showing the playlist we proved, not the group the hint keeps naming. + if (provenPlaylistName) state.playlistName = provenPlaylistName; + return; + } + if (searchCooldown > 0) { + searchCooldown -= 1; + return; + } + const found = await findPlaylistContainingLive(liveName, state.livePresentationUuid); + if (found) { + state.items = found.items; + state.playlistName = found.name; + provenPlaylistName = found.name; + state.playlistUuid = found.uuid || state.playlistUuid; + state.playlistKind = 'playlist'; + await fillSlideCounts(); + recomputeActiveIndex(); + } else { + // Nothing in the tree holds it — genuinely presenting outside a playlist. Back off so + // a full tree walk cannot run on every poll. + searchCooldown = 25; + } + } + + /** + * Who is on screen when slide_index won't say. /v1/presentation/focused reports the + * presentation ProPresenter has focused — including one opened straight from the Library, + * which slide_index attributes to nothing. No slide number is available this way, so the + * card shows the title and the total without a running count. + */ + async function applyFocusedPresentation() { + try { + const focused = await requestJson('/v1/presentation/focused'); + const uuid = String(focused?.uuid || focused?.id?.uuid || ''); + const name = String(focused?.name || focused?.id?.name || ''); + if (!name && !uuid) return; + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationUuid = uuid; + state.livePresentationSlideTotal = 0; + } + state.activeName = name || state.activeName; + recomputeActiveIndex(); + } catch { + /* leave the last known state alone */ + } + } + + async function fillSlideCounts() { + for (const item of state.items) { + // Try a slide count for anything that isn't an explicit header; non-presentations + // simply return null (their UUID has no presentation), which we render as "no count". + if (item.uuid && item.type !== 'header' && item.slideCount === null) { + item.slideCount = await slideCount(item.uuid); + } + } + } + + function applySlideIndex(data) { + // PP20 uses `presentation_index`; older versions used `presentation`. + const node = data?.presentation_index || data?.presentation || null; + if (!node) { + // A null here does NOT mean the screen is empty. On ProPresenter 20 this endpoint + // reports null whenever the live slide isn't attributed to a playlist presentation — + // presenting from the Library is the common case — and it also blips to null between + // triggers. Blanking on it is what made the display keep clearing itself during a + // rehearsal. /v1/status/layers is the authority on whether anything is on screen: + // while the slide layer is live, hold the last known item. + if (state.slideLayerLive) return; + state.currentSlide = 0; + state.livePresentationUuid = ''; + state.activeUuid = ''; + state.activeName = ''; + recomputeActiveIndex(); + return; + } + const idx = Number.isInteger(node.index) ? node.index : null; + state.currentSlide = idx !== null ? idx + 1 : 0; + + const presId = node?.presentation_id || node?.id || null; + const uuid = presId ? String(presId.uuid || '') : ''; + state.livePresentationUuid = uuid; + + // Use this uuid as the active item — it's always accurate regardless of how slides advanced. + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationSlideTotal = 0; // reset so a fresh count is fetched this poll + // Try to find the name from the item list; fall back to presentation_id.name. + const match = state.items.find((item) => item.uuid === uuid); + state.activeName = match ? match.name : String(presId?.name || ''); + recomputeActiveIndex(); + } + } + + /** Project the internal poll state into the view-model both UIs render. */ + function getViewModel() { + // Items the operator marked hidden in ProPresenter — cut songs, contingency cues, + // staff-only slides — are REDACTED here, not removed. Both UIs already skip hidden + // rows when rendering, but their names were still being published to every screen on + // the network. Keeping the entries (minus their names) preserves the positions that + // activeIndex and the "what's next" slice depend on, which dropping them would shift. + const items = state.items.map((item) => (item.isHidden + ? { uuid: '', name: '', type: item.type, isHidden: true, isPco: false, slideCount: null } + : item)); + + // Use the live count for the presentation that's actually on screen — it reflects the + // arrangement playing right now, so it can never be stale or mismatched. + return { + enabled: config.enabled, + reachable: state.reachable, + lastError: state.lastError || '', + needsPlaylist: false, + playlistName: state.playlistName, + playlistKind: state.playlistKind || 'none', + items, + activeIndex: state.activeIndex, + activePresentationName: state.activeName, + currentSlide: state.currentSlide, + currentSlideTotal: state.livePresentationSlideTotal || 0, + }; + } + + /** Last error thrown by a consumer's onData, for callers that want to surface it. */ + let lastEmitError = ''; + + function emit() { + if (!onData) return; + // A consumer must never be able to stop the engine. emit() runs OUTSIDE pollTick's + // try/catch and BEFORE the reschedule below it, so an unhandled throw here would end + // polling for good — and with no unhandledRejection handler installed (Electron adds + // none) it would take the whole process down. Swallow, record, keep polling. + try { + onData(getViewModel()); + lastEmitError = ''; + } catch (err) { + lastEmitError = err instanceof Error ? err.message : String(err); + console.error('[propresenter-core] onData consumer threw:', err); + } + } + + async function pollTick(token) { + if (stopped || token !== runToken) return; + + try { + tick += 1; + + // THE HOT PATH — one request, every tick, exactly like version 1. This is the live + // slide counter, and it must not be starved by anything heavier sharing the loop: + // ProPresenter's little HTTP server will start returning null for slide_index if it + // is hammered, which is what stopped the counter from updating. + const raw = await requestJson('/v1/presentation/slide_index'); + lastIndexWasNull = !(raw?.presentation_index || raw?.presentation); + applySlideIndex(raw); + state.reachable = true; + state.lastError = ''; + + // Slide TOTAL for the live presentation (cached per uuid — after the first probe this + // costs nothing, so it can stay on the hot path). + if (state.livePresentationUuid) { + try { + const activeItem = state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const hint = arrangementTagFromName(activeItem?.name || state.activeName); + state.livePresentationSlideTotal = await liveSlideTotal( + state.livePresentationUuid, + state.currentSlide, + hint, + ); + } catch { + /* keep the last known total */ + } + } + + // EVERYTHING BELOW is the slow lane — the calls I added that ProPresenter does not + // need to answer on every slide. Run them roughly every 2.5s, or immediately when we + // still have no idea what is on screen. Keeps steady-state load below version 1's. + const slowTick = tick % 4 === 0; + const needBootstrap = !state.items.length || (!state.activeName && lastIndexWasNull); + + if (slowTick || needBootstrap) { + // Only ask about layers/focused when slide_index isn't naming the slide itself. + if (lastIndexWasNull) { + try { + const layers = await requestJson('/v1/status/layers'); + if (layers && typeof layers.slide === 'boolean') state.slideLayerLive = layers.slide; + } catch { + /* older builds have no layer status; leave the last value */ + } + if (state.slideLayerLive && !state.activeName) { + await applyFocusedPresentation(); + } + } + await refreshPlaylistContext(); + } + + if (state.activeUuid) { + const match = state.items.find((item) => item.uuid === state.activeUuid); + if (match) state.activeName = match.name; + } + recomputeActiveIndex(); + } catch (err) { + state.reachable = false; + state.lastError = err instanceof Error ? err.message : String(err); + } + + if (stopped || token !== runToken) return; + emit(); + pollTimer = setTimeout(() => pollTick(token), state.reachable ? pollMs : retryMs); + } + + function stop() { + stopped = true; + runToken += 1; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + state.reachable = false; + } + + function start() { + if (!config.enabled || !config.host || !config.port) return false; + stopped = false; + const token = (runToken += 1); + pollTick(token); + return true; + } + + /** Restart the poll loop only if it was already running (used after a config tweak). */ + function restartIfRunning() { + if (!stopped) { + stop(); + start(); + } + } + + /** Drop every cache + poll result. Call when the connection target changes. */ + function reset() { + slideCountCache.clear(); + arrangementCountCache.clear(); + itemsSourceUrl = ''; + itemsForUuid = ''; + state = emptyPollState(); + } + + /** + * Point the client at a ProPresenter instance. Does not start polling. + * Accepts a partial config; omitted fields keep their current value. + */ + function configure(next = {}) { + const merged = { + enabled: next.enabled ?? config.enabled, + host: next.host ?? config.host, + port: next.port ?? config.port, + playlistUuid: next.playlistUuid ?? config.playlistUuid, + playlistName: next.playlistName ?? config.playlistName, + }; + const normalized = normalizeConfig(merged); + config = { + ...normalized, + // enabled is only meaningful with a host+port, but callers may enable optimistically + // before the reachability check; normalizeConfig already guards that. + enabled: Boolean(merged.enabled) && Boolean(normalized.host) && Boolean(normalized.port), + password: next.password ?? config.password, + }; + return getConfig(); + } + + /** The current config, minus the password (callers get `hasPassword` instead). */ + function getConfig() { + return { + enabled: config.enabled, + host: config.host, + port: config.port, + hasPassword: Boolean(config.password), + playlistUuid: config.playlistUuid, + playlistName: config.playlistName, + }; + } + + /** + * Hit /version to confirm the API is reachable and the password (if any) is accepted. + * Resolves with ProPresenter's version payload, or throws a message worth showing a human. + */ + async function testConnection() { + if (!config.host || !config.port) { + throw new Error('Enter the ProPresenter host (IP) and port.'); + } + try { + return await requestJson('/version'); + } catch { + throw new Error( + `Could not reach ProPresenter at ${config.host}:${config.port}. Check the IP and port, and that the API is enabled in ProPresenter ▸ Preferences ▸ Network.`, + ); + } + } + + /** Every playlist ProPresenter knows about, flattened for a picker. */ + async function listPlaylists() { + if (!config.host || !config.port) return []; + const data = await requestJson('/v1/playlists'); + const nodes = Array.isArray(data) + ? data + : Array.isArray(data?.playlists) + ? data.playlists + : Array.isArray(data?.children) + ? data.children + : []; + return flattenPlaylists(nodes, '', []); + } + + /** Remember a manually chosen playlist and force the next poll to refetch its items. */ + function selectPlaylist({ uuid = '', name = '' } = {}) { + config.playlistUuid = String(uuid); + config.playlistName = String(name); + state.playlistUuid = ''; + state.items = []; + state.activeIndex = -1; + restartIfRunning(); + return getConfig(); + } + + return { + configure, + getConfig, + setOnData(cb) { onData = typeof cb === 'function' ? cb : null; }, + start, + stop, + restartIfRunning, + // NOTE: isRunning() is just !stopped — it says the loop was started, NOT that + // ProPresenter is reachable or that data is flowing. Use getViewModel().reachable for health. + isRunning: () => !stopped, + getLastEmitError: () => lastEmitError, + reset, + getViewModel, + testConnection, + listPlaylists, + selectPlaylist, + requestJson, + }; +} + +module.exports = { + DEFAULT_PP_PORT, + DEFAULT_POLL_MS, + DEFAULT_RETRY_MS, + createProPresenterClient, + clearedViewModel, + normalizeConfig, + flattenPlaylists, + parsePlaylistItems, + findPlaylistPath, + countSlides, + cleanItemName, +}; diff --git a/modules/propresenter-now-next/propresenter-core/package.json b/modules/propresenter-now-next/propresenter-core/package.json new file mode 100644 index 0000000..be1048a --- /dev/null +++ b/modules/propresenter-now-next/propresenter-core/package.json @@ -0,0 +1,8 @@ +{ + "name": "propresenter-core", + "version": "1.0.0", + "private": true, + "description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)", + "main": "index.js", + "license": "MIT" +} diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js new file mode 100644 index 0000000..4ef26e4 --- /dev/null +++ b/modules/propresenter-now-next/server.js @@ -0,0 +1,132 @@ +'use strict'; + +/** + * ProPresenter Now / Next — server part. + * + * Owns the one ProPresenter connection (via propresenter-core, copied here + * unchanged — it holds every ProPresenter 7/20 quirk) and fans the live + * view-model 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) + */ + +const { createProPresenterClient, clearedViewModel } = require('./propresenter-core'); + +/** 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; + +module.exports = { + init({ config, log }) { + const streams = new Set(); + const enabled = Boolean(config.host && Number(config.port)); + let latest = clearedViewModel(); + let lastFrame = ''; + + 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); + } + } + }, + }); + + client.configure({ + enabled, + host: String(config.host || ''), + port: Number(config.port) || 1025, + password: String(config.password || ''), + }); + + if (enabled) { + client.start(); + log(`following ProPresenter at ${config.host}:${config.port}`); + } 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, getLatest: () => latest }; + + return { + stop() { + clearInterval(heartbeat); + client.stop(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current && current.streams === streams) current = null; + }, + health() { + if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; + if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` }; + return { + status: 'error', + message: latest.lastError + ? `ProPresenter unreachable: ${latest.lastError}` + : 'Trying to reach ProPresenter…', + }; + }, + }; + }, + + 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.getLatest() : clearedViewModel() })); + }, + + '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'); + const view = current ? current.getLatest() : clearedViewModel(); + res.write(`event: state\ndata: ${JSON.stringify(view)}\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-now-next/style.css b/modules/propresenter-now-next/style.css new file mode 100644 index 0000000..8ef2efc --- /dev/null +++ b/modules/propresenter-now-next/style.css @@ -0,0 +1,297 @@ +/* ProPresenter Now / Next module — the wall display's visual language, + scaled to a tile. All sizes hang off --unit (set from the tile height by + client.js); all colors come from the shell's variables. */ + +[data-module="propresenter-now-next"] .nn-screen { + --unit: 12px; + --nn-pad: calc(var(--unit) * 1.1); + --nn-gap: calc(var(--unit) * 0.7); + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: var(--nn-gap); + padding: var(--nn-pad); + user-select: none; + -webkit-user-select: none; +} + +/* With no NEXT list there is nothing below the card — centre it. */ +[data-module="propresenter-now-next"] .nn-screen.no-upcoming { + justify-content: center; +} + +/* ── top row ── */ + +[data-module="propresenter-now-next"] .nn-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--nn-gap); + flex-shrink: 0; + min-height: 0; +} +[data-module="propresenter-now-next"] .nn-top.hidden-row { display: none; } + +/* Losing the feed is the one thing the tile MUST admit to — overrides the + "hide the top row" preference. */ +[data-module="propresenter-now-next"] .is-offline .nn-top, +[data-module="propresenter-now-next"] .nn-screen.is-offline .nn-top { + display: flex !important; +} +[data-module="propresenter-now-next"] .is-offline .nn-live-text { + opacity: 1 !important; + color: var(--danger); +} +[data-module="propresenter-now-next"] .is-offline .nn-now { + border-color: color-mix(in srgb, var(--danger) 50%, transparent); +} + +[data-module="propresenter-now-next"] .nn-playlist { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.0); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="propresenter-now-next"] .nn-live { + display: flex; + align-items: center; + gap: calc(var(--unit) * 0.45); + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-live-dot { + width: calc(var(--unit) * 0.6); + height: calc(var(--unit) * 0.6); + border-radius: 999px; + background: var(--border); + transition: background 0.3s ease, box-shadow 0.3s ease; +} +[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-dot { + background: var(--accent); + box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--accent) 18%, transparent); +} +[data-module="propresenter-now-next"] .nn-live.is-down .nn-live-dot { + background: var(--danger); + box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--danger) 16%, transparent); +} + +/* Idle tiles don't need a status chip staring at them — it fades once live. */ +[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-text { + opacity: 0; + transition: opacity 0.6s ease 1.4s; +} + +/* ── NOW card ── + FIXED height so the title box is deterministic and the NOW/NEXT layout + cannot oscillate (see the wall display's notes). */ + +[data-module="propresenter-now-next"] .nn-now { + flex: 0 0 auto; + height: 42%; + min-height: calc(var(--unit) * 7); + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: calc(var(--unit) * 0.9); + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--accent) 9%, transparent), + color-mix(in srgb, var(--bg) 60%, transparent) + ); + padding: calc(var(--nn-pad) * 0.8) var(--nn-pad); +} + +[data-module="propresenter-now-next"] .nn-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent); +} + +[data-module="propresenter-now-next"] .nn-title-box { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + overflow: hidden; + margin-top: calc(var(--unit) * 0.3); +} + +[data-module="propresenter-now-next"] .nn-title { + width: 100%; + /* Font size is set by fitNowTitle() so the title fills the box; keep + line-height ≥ the font's natural ~1.2 or large ascenders/descenders clip. */ + font-size: calc(var(--unit) * 2.4); + font-weight: 700; + line-height: 1.2; + color: var(--text); + /* Wrapping is controlled by fitNowTitle(), which forbids mid-word breaks. */ + overflow-wrap: normal; + word-break: normal; +} + +/* Status text, not a title: readable size, never the fill-the-box treatment. */ +[data-module="propresenter-now-next"] .nn-title.is-waiting { + color: var(--muted); + font-weight: 600; + font-size: calc(var(--unit) * 1.5); + white-space: normal; +} + +[data-module="propresenter-now-next"] .nn-hint { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 0.95); + line-height: 1.4; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-stale { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 0.95); + font-weight: 600; + color: var(--danger); +} + +[data-module="propresenter-now-next"] .nn-meta { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.45); + font-size: calc(var(--unit) * 1.15); + font-weight: 600; + color: var(--text); + opacity: 0.85; + font-variant-numeric: tabular-nums; + /* Never wrap: a wrap changes the title box height, which refits the title. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="propresenter-now-next"] .nn-meta:empty { display: none; } + +[data-module="propresenter-now-next"] .nn-progress { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.55); + height: calc(var(--unit) * 0.4); + border-radius: 999px; + background: color-mix(in srgb, var(--text) 10%, transparent); + overflow: hidden; +} +[data-module="propresenter-now-next"] .nn-progress[hidden] { display: none; } + +[data-module="propresenter-now-next"] .nn-progress-fill { + height: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 65%, var(--panel)); + transition: width 0.25s ease; +} + +/* ── NEXT list ── */ + +[data-module="propresenter-now-next"] .nn-next-wrap { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.5); +} + +[data-module="propresenter-now-next"] .nn-next-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} +[data-module="propresenter-now-next"] .nn-next-label[hidden] { display: none; } + +[data-module="propresenter-now-next"] .nn-next-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.45); + overflow: hidden; +} + +[data-module="propresenter-now-next"] .nn-next-item { + display: flex; + align-items: center; + gap: var(--nn-gap); + padding: calc(var(--unit) * 0.6) calc(var(--unit) * 0.85); + border: 1px solid var(--border); + border-radius: calc(var(--unit) * 0.6); + background: color-mix(in srgb, var(--bg) 70%, transparent); +} + +[data-module="propresenter-now-next"] .nn-next-name { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.25); + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="propresenter-now-next"] .nn-count-chip { + flex-shrink: 0; + min-width: calc(var(--unit) * 2); + padding: calc(var(--unit) * 0.15) calc(var(--unit) * 0.5); + text-align: center; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--text); + font-size: calc(var(--unit) * 0.95); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +[data-module="propresenter-now-next"] .nn-type-tag { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.8); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-next-item.is-header { + background: none; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: calc(var(--unit) * 0.55) calc(var(--unit) * 0.1) calc(var(--unit) * 0.2); +} +[data-module="propresenter-now-next"] .nn-next-item.is-header .nn-next-name { + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-next-more { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + color: var(--muted); + font-weight: 600; +} +[data-module="propresenter-now-next"] .nn-next-more[hidden] { display: none; }