Files
band-lineup-display/tmp/pp-mock.js
T
christianandClaude Opus 5 96ac2dec2d Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with
mic/pack labels, packaged for Windows. It also carries the ProPresenter
Now/Next feature in two forms that share one engine — propresenter-core, where
every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone
display it serves.

Until now this existed only on the production machine and as a read-only
snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build
output, and credentials, photos and processed images are written to the
Electron userData directory, never here. tmp/pp-mock.js stays — it is the
ProPresenter mock ProdDash's own CLAUDE.md tells people to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

199 lines
8.5 KiB
JavaScript

/**
* Mock ProPresenter 7 network API for verifying the Band Lineup integration
* without a live ProPresenter. Serves the documented endpoint shapes and a
* chunked /v1/status/updates stream that advances the active slide on a timer.
*
* node tmp/pp-mock.js [port] (default 1599)
*
* Every incoming request is logged so we can confirm what the app fetches.
*/
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
const STEP_MS = Number.parseInt(process.env.PP_STEP_MS || '1500', 10);
// Playlist: header + presentations + a media item + a hidden item.
// `slides` = unique master slides returned by /v1/presentation/{uuid} (what ppCountSlides sees).
// `arr` = cues the SELECTED arrangement actually plays (what slide_index + thumbnails respect).
// When arr > slides the arrangement repeats a section (e.g. a chorus) — this is the 43/41 case.
const ITEMS = [
{ uuid: 'I0', name: 'Pre-Service Loop', type: 'header', is_hidden: false, is_pco: false },
{ uuid: 'I1', name: 'Welcome', type: 'presentation', is_hidden: false, is_pco: false, slides: 3 },
{ uuid: 'I2', name: 'This Is The Day - [ Full ]', type: 'presentation', is_hidden: false, is_pco: false, slides: 41, arr: 43 },
{ uuid: 'I3', name: 'Sermon: Living Hope', type: 'presentation', is_hidden: false, is_pco: true, slides: 5 },
{ uuid: 'I4', name: 'Bumper Video', type: 'media', is_hidden: false, is_pco: false },
{ uuid: 'I5', name: 'Closing Song', type: 'presentation', is_hidden: false, is_pco: false, slides: 8 },
{ uuid: 'IH', name: 'Hidden Cue', type: 'presentation', is_hidden: true, is_pco: false, slides: 4 },
{ uuid: 'I6', name: 'Benediction', type: 'presentation', is_hidden: false, is_pco: false, slides: 2 },
];
/** Arrangement-expanded cue count for an item (defaults to its master slide count). */
function arrLen(item) {
return item && item.arr ? item.arr : (item && item.slides ? item.slides : 1);
}
const PLAYLIST = { uuid: 'PL1', name: 'Sunday AM — June 1', index: 0 };
// Only presentation items can be "active". Start on Amazing Grace.
const PRESENTATIONS = ITEMS.filter((it) => it.type === 'presentation');
let activePresIdx = PRESENTATIONS.findIndex((it) => it.uuid === 'I2');
let slide = 0;
function activeItem() {
return PRESENTATIONS[activePresIdx];
}
function playlistIndexOfActive() {
return ITEMS.findIndex((it) => it.uuid === activeItem().uuid);
}
function slideIndexPayload() {
// PP20 shape: top-level `presentation_index`.
const item = activeItem();
return {
presentation_index: {
index: slide,
presentation_id: { uuid: item.uuid, name: item.name, index: playlistIndexOfActive() },
},
};
}
function presentationPayload(uuid) {
// PP20 shape: details wrapped under `presentation`.
const item = ITEMS.find((it) => it.uuid === uuid);
const n = item && item.slides ? item.slides : 0;
const slides = Array.from({ length: n }, (_s, i) => ({
enabled: true,
notes: '',
text: `${item ? item.name : ''} — slide ${i + 1}`,
image: '',
label: '',
color: { red: 0, green: 0, blue: 0, alpha: 0 },
}));
return {
presentation: {
id: { uuid, name: item ? item.name : '', index: 0 },
name: item ? item.name : '',
index: 0,
groups: [{ name: 'Group', color: {}, slides }],
has_timeline: false,
},
};
}
function playlistItemsPayload() {
// ProPresenter 20 style: items carry id{uuid,name,index} + field_type.
return {
id: PLAYLIST,
items: ITEMS.map((it, index) => ({
id: { uuid: it.uuid, name: it.name, index },
field_type: it.type,
is_hidden: it.is_hidden,
is_pco: it.is_pco,
})),
};
}
function currentItemId() {
const it = activeItem();
return { uuid: it.uuid, name: it.name, index: ITEMS.findIndex((x) => x.uuid === it.uuid) };
}
// ProPresenter 20: what's live and what's focused (auto-follow sources).
function activePlaylistPayload() {
return { presentation: { playlist: PLAYLIST, item: currentItemId() }, announcements: { playlist: null, item: null } };
}
function focusedPlaylistPayload() {
return { playlist: PLAYLIST, item: currentItemId() };
}
// ProPresenter 20 /v1/playlists returns a tree of groups → playlists.
function playlistsTreePayload() {
return [
{
id: { uuid: 'GRP-SERVICES', name: 'Services', index: 0 },
field_type: 'group',
children: [
{ id: { uuid: PLAYLIST.uuid, name: PLAYLIST.name, index: 0 }, field_type: 'playlist', children: [] },
{ id: { uuid: 'PL-OTHER', name: 'Speaker Pics', index: 1 }, field_type: 'playlist', children: [] },
],
},
];
}
const subscribers = new Set();
let lastPollAt = 0; // updated when a client polls slide_index, so we also advance under polling clients
function broadcastSlideIndex() {
const line = JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n';
for (const res of subscribers) res.write(line);
}
// Advance: walk slides of the active presentation, then jump to the next presentation item.
// Only advance once something is subscribed, so REST snapshots before subscribing are deterministic.
setInterval(() => {
if (subscribers.size === 0 && Date.now() - lastPollAt > 3000) return;
const item = activeItem();
slide += 1;
if (slide >= arrLen(item)) {
slide = 0;
activePresIdx = (activePresIdx + 1) % PRESENTATIONS.length;
console.log(`[mock] → now presenting "${activeItem().name}"`);
}
broadcastSlideIndex();
}, STEP_MS);
function sendJson(res, obj) {
const body = JSON.stringify(obj);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(body);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
console.log(`[mock] ${req.method} ${p}`);
if (p === '/version') return sendJson(res, { name: 'NA-ProP (mock)', platform: 'mac', host_description: 'ProPresenter 20.0.1', api_version: 'v1' });
if (p === '/v1/playlists') return sendJson(res, playlistsTreePayload());
if (p === '/v1/playlist/active') return sendJson(res, activePlaylistPayload());
if (p === '/v1/playlist/focused') return sendJson(res, focusedPlaylistPayload());
// Simulate PP20: the items list lives at a renamed route (uuid/dot/colon all 404).
// Services group index 0, First Tuesday index 0 → dash-joined path "0-0".
if (p === '/v1/playlist/0-0') return sendJson(res, playlistItemsPayload());
if (p === '/v1/presentation/slide_index') { lastPollAt = Date.now(); return sendJson(res, slideIndexPayload()); }
// Thumbnail probe: index "respects the selected arrangement" → valid for 0..arrLen-1, else 404.
const thumb = p.match(/^\/v1\/presentation\/([^/]+)\/thumbnail\/(\d+)$/);
if (thumb) {
const item = ITEMS.find((it) => it.uuid === decodeURIComponent(thumb[1]));
const index = Number.parseInt(thumb[2], 10);
if (item && index >= 0 && index < arrLen(item)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
return res.end(Buffer.from([0xff, 0xd8, 0xff, 0xd9])); // tiny stub JPEG
}
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'index out of range' }));
}
if (p === '/v1/presentation/current' || p === '/v1/presentation/active') return sendJson(res, presentationPayload(activeItem().uuid));
if (p.startsWith('/v1/presentation/')) {
const uuid = decodeURIComponent(p.split('/').pop());
return sendJson(res, presentationPayload(uuid));
}
if (p === '/v1/status/updates' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
let urls = [];
try { urls = JSON.parse(body); } catch { /* ignore */ }
console.log(`[mock] status/updates subscribe: ${JSON.stringify(urls)}`);
res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
// initial snapshot for each subscribed url
if (urls.includes('playlist/current')) res.write(JSON.stringify({ url: 'playlist/current', data: PLAYLIST }) + '\n');
if (urls.includes('presentation/slide_index')) res.write(JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n');
subscribers.add(res);
res.on('close', () => subscribers.delete(res));
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[mock] ProPresenter mock API on http://127.0.0.1:${PORT} (slide step ${STEP_MS}ms)`);
});