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 = `
+
`;
+
+ 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; }