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:
@@ -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); });
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user