ProdDash: new project with reference forks of ProdCom Listener and BandLineupDisplay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-08-30 10:09:41 -04:00
co-authored by Claude Fable 5
commit 150797389e
45 changed files with 18299 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
/**
* 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)`);
});
@@ -0,0 +1,47 @@
// Verifies the slide-count + "remaining" logic (mirrors main.js) against tmp/pp-mock.js.
// The total comes from probing the thumbnail boundary (arrangement-expanded cue count),
// which lives in the SAME index space as slide_index — so "Slide X / Y" can never have X > Y,
// even when the selected arrangement repeats a section (the 43/41 bug this fixes).
const HOST = '127.0.0.1';
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
async function get(p) {
const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`${p} -> ${r.status}`);
return r.json();
}
function countSlides(d) {
const p = d?.presentation || d;
const groups = Array.isArray(p?.groups) ? p.groups : [];
let c = 0;
for (const g of groups) c += Array.isArray(g?.slides) ? g.slides.length : 0;
return c;
}
// Mirror of ppProbeArrangementLength: smallest thumbnail index that 404s = cue count.
async function status(p) {
const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'image/jpeg' } });
return r.status;
}
async function probeArrangementLength(uuid) {
const exists = async (i) => (await status(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${i}`)) === 200;
if (!(await exists(0))) return 0;
let lo = 0; let hi = 1;
while (await exists(hi)) { lo = hi; hi *= 2; if (hi > 4000) return null; }
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (await exists(mid)) lo = mid; else hi = mid; }
return lo + 1;
}
(async () => {
const si = await get('/v1/presentation/slide_index');
const node = si?.presentation_index || si?.presentation;
const current = node && Number.isInteger(node.index) ? node.index + 1 : 0;
const uuid = node?.presentation_id?.uuid || '';
const det = await get(`/v1/presentation/${encodeURIComponent(uuid)}`);
const master = countSlides(det);
const total = await probeArrangementLength(uuid);
const remaining = Math.max(0, total - current);
console.log(`live presentation uuid: ${uuid}`);
console.log(`master slides (/presentation/{uuid}): ${master} arrangement cues (thumbnail probe): ${total}`);
console.log(`NOW card → "Slide ${current} / ${total} · ${remaining} left"`);
const ok = current > 0 && total > 0 && total >= current && remaining === total - current;
console.log(ok ? '\n✅ total matches the slide_index space (X never exceeds Y)' : '\n❌ FAILED');
process.exit(ok ? 0 : 1);
})().catch((e) => { console.error('error:', e.message); process.exit(2); });
+139
View File
@@ -0,0 +1,139 @@
/**
* Verifies the ProPresenter data pipeline used by main.js against tmp/pp-mock.js.
* Mirrors the main-process client logic (stream parsing, slide-count summing,
* active-item matching, NOW/NEXT assembly) and asserts the resulting view-model.
*
* node tmp/pp-verify.js [port]
*/
const HOST = '127.0.0.1';
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
let failures = 0;
function check(label, cond) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`);
if (!cond) failures += 1;
}
// ---- client logic mirrored from main.js ----
const slideCountCache = new Map();
let state = { reachable: false, playlistUuid: '', playlistName: '', items: [], activeUuid: '', activeName: '', activeIndex: -1, currentSlide: 0 };
function ppUrl(target) { return `http://${HOST}:${PORT}${target}`; }
async function ppRequestJson(target, init) {
const r = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, ...init });
if (!r.ok) throw new Error(`${r.status}`);
const t = await r.text();
return t ? JSON.parse(t) : null;
}
async function ppSlideCount(uuid) {
if (!uuid) return null;
if (slideCountCache.has(uuid)) return slideCountCache.get(uuid);
try {
const data = await ppRequestJson(`/v1/presentation/${encodeURIComponent(uuid)}`);
let count = 0;
for (const g of data?.groups || []) for (const s of g?.slides || []) if (s?.enabled !== false) count += 1;
slideCountCache.set(uuid, count);
return count;
} catch { return null; }
}
function recomputeActiveIndex() {
state.activeIndex = state.activeUuid ? state.items.findIndex((it) => it.uuid && it.uuid === state.activeUuid) : -1;
}
async function refreshCurrentPlaylist() {
const cur = await ppRequestJson('/v1/playlist/current');
state.playlistUuid = String(cur?.uuid || '');
state.playlistName = String(cur?.name || '');
if (!state.playlistUuid) { state.items = []; recomputeActiveIndex(); return; }
const data = await ppRequestJson(`/v1/playlist/${encodeURIComponent(state.playlistUuid)}`);
state.items = (data?.items || []).map((it) => ({
uuid: String(it?.id?.uuid || ''), name: String(it?.id?.name || ''), type: String(it?.type || ''),
isHidden: Boolean(it?.is_hidden), isPco: Boolean(it?.is_pco), slideCount: null,
}));
recomputeActiveIndex();
}
async function fillSlideCounts() {
for (const it of state.items) if (it.type === 'presentation' && it.uuid && it.slideCount === null) it.slideCount = await ppSlideCount(it.uuid);
}
function applySlideIndex(data) {
const pres = data?.presentation;
if (!pres || !pres.presentation_id) { state.activeUuid = ''; state.activeName = ''; state.currentSlide = 0; recomputeActiveIndex(); return; }
state.currentSlide = Number.isInteger(pres.index) ? pres.index + 1 : 0;
state.activeUuid = String(pres.presentation_id.uuid || '');
state.activeName = String(pres.presentation_id.name || '');
recomputeActiveIndex();
}
async function viewModel() {
let total = 0;
if (state.activeUuid) { const c = slideCountCache.get(state.activeUuid); total = Number.isInteger(c) ? c : (await ppSlideCount(state.activeUuid)) || 0; }
return { reachable: state.reachable, playlistName: state.playlistName, items: state.items, activeIndex: state.activeIndex, activePresentationName: state.activeName, currentSlide: state.currentSlide, currentSlideTotal: total };
}
function describe(vm) {
const now = vm.activeIndex >= 0 ? vm.items[vm.activeIndex].name : vm.activePresentationName;
const from = vm.activeIndex >= 0 ? vm.activeIndex + 1 : 0;
const next = vm.items.slice(from).filter((it) => !it.isHidden)
.map((it) => it.type === 'presentation' ? `${it.name} (${it.slideCount})` : `${it.name} [${it.type}]`);
return `NOW: ${now} — slide ${vm.currentSlide}/${vm.currentSlideTotal}\n NEXT: ${next.join(' · ')}`;
}
async function main() {
await refreshCurrentPlaylist();
applySlideIndex(await ppRequestJson('/v1/presentation/slide_index'));
await fillSlideCounts();
state.reachable = true;
const vm = await viewModel();
console.log('\n--- initial view-model ---\n' + describe(vm) + '\n');
const byName = (n) => vm.items.find((it) => it.name === n);
check('playlist name', vm.playlistName === 'Sunday AM — June 1');
check('item count = 8', vm.items.length === 8);
check('Welcome slides = 3', byName('Welcome').slideCount === 3);
check('Amazing Grace slides = 12', byName('Amazing Grace').slideCount === 12);
check('Sermon slides = 5', byName('Sermon: Living Hope').slideCount === 5);
check('Closing slides = 8', byName('Closing Song').slideCount === 8);
check('Benediction slides = 2', byName('Benediction').slideCount === 2);
check('media item has no count', byName('Bumper Video').slideCount === null);
check('header item has no count', byName('Pre-Service Loop').slideCount === null);
check('active is Amazing Grace', vm.activeIndex === vm.items.findIndex((it) => it.name === 'Amazing Grace'));
check('current slide >= 1', vm.currentSlide >= 1);
check('current total = 12', vm.currentSlideTotal === 12);
const from = vm.activeIndex + 1;
const nextNames = vm.items.slice(from).filter((it) => !it.isHidden).map((it) => it.name);
check('NEXT excludes hidden cue', !nextNames.includes('Hidden Cue'));
check('NEXT starts with Sermon', nextNames[0] === 'Sermon: Living Hope');
// Now exercise the live chunked stream for a few updates.
console.log('\n--- streaming live updates ---');
setTimeout(() => { console.error('TIMEOUT waiting for stream updates'); process.exit(3); }, 8000);
const res = await fetch(ppUrl('/v1/status/updates'), {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(['presentation/slide_index', 'playlist/current']),
});
check('stream opened (chunked)', res.ok && !!res.body);
let updates = 0;
const seenSlides = [];
let buffer = '';
const decoder = new TextDecoder();
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
if (String(msg.url).includes('presentation/slide_index')) {
applySlideIndex(msg.data);
seenSlides.push(state.currentSlide);
console.log(describe(await viewModel()).split('\n')[0]);
updates += 1;
}
}
if (updates >= 4) break;
}
check('received >= 4 live slide updates', updates >= 4);
check('slide value changed across updates', new Set(seenSlides).size >= 2);
console.log(`\n${failures === 0 ? '✅ ALL CHECKS PASSED' : `❌ ${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => { console.error('verify error:', e); process.exit(2); });