Files
ProdDash/band-lineup-display/propresenter-core/index.js

1030 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};