/* * mock-propresenter.cjs — a stand-in ProPresenter for testing the Sync tab without the real thing. * * node tools/mock-propresenter.cjs [port=10250] * * Speaks enough of both protocols the app uses: * Pro 7 REST: GET /version, /v1/status/slide[?chunked=true], /v1/presentation/slide_index[?chunked=true], * /v1/announcement/slide_index[?chunked=true], /v1/presentation/{uuid}, /v1/announcement/active * Pro 6 style: ws://host:port/stagedisplay (auth {pwd, ptl, acn:'ath'} -> 'fv' frames with cs/csn) * password: "stage" * Control page at / (Next / Prev / jump), plus GET /mock/next, /mock/prev, /mock/goto/{n}, /mock/state. */ const http = require('http'); const crypto = require('crypto'); const PORT = parseInt(process.argv[2], 10) || 10250; const STAGE_PASSWORD = 'stage'; const PRESENTATION = { id: { uuid: 'A1B2C3D4-0000-4000-8000-000000000001', name: 'Sunday — Pastor Notes', index: 0 }, groups: [ { name: 'Welcome', color: {}, slides: [ { enabled: true, label: 'Welcome', notes: '', text: 'Welcome to Waters Church', size: {} }, { enabled: true, label: 'Announcements', notes: 'Coffee after service', text: 'Announcements', size: {} } ] }, { name: 'Message', color: {}, slides: [ { enabled: true, label: 'John 3:16', notes: 'John 3:16 (NIV)', text: 'For God so loved the world that he gave his one and only Son, that whoever believes in him shall not perish but have eternal life.', size: {} }, { enabled: true, label: '', notes: 'Romans 8:28 NIV', text: 'And we know that in all things God works for the good of those who love him, who have been called according to his purpose.', size: {} }, { enabled: true, label: '', notes: '', text: 'Love is patient, love is kind. It does not envy, it does not boast, it is not proud.\n1 Corinthians 13:4–7', size: {} }, { enabled: true, label: 'Point 2', notes: 'transition slide, no scripture', text: 'God is faithful', size: {} }, { enabled: true, label: 'Ps 23', notes: '', text: 'The LORD is my shepherd; I shall not want.', size: {} }, { enabled: true, label: 'Rev. 21:4', notes: '', text: 'He will wipe every tear from their eyes.', size: {} }, { enabled: true, label: 'John 4:100', notes: '', text: 'out of range verse — clamp test', size: {} }, { enabled: true, label: 'Isaiah 53:5', notes: '', text: 'But he was pierced for our transgressions', size: {} } ] }, { name: 'Closing', color: {}, slides: [ { enabled: true, label: 'Amazing Grace', notes: '', text: 'Amazing grace, how sweet the sound', size: {} } ] } ] }; const FLAT = PRESENTATION.groups.flatMap(g => g.slides); let index = 0; const streams = new Set(); // { res, kind } const wsClients = new Set(); // sockets let eventsSent = 0; function slideIndexDoc() { return { presentation_index: { index, presentation_id: PRESENTATION.id } }; } function statusSlideDoc() { const cur = FLAT[index] || { text: '', notes: '' }; const nxt = FLAT[index + 1] || { text: '', notes: '' }; return { current: { text: cur.text, notes: cur.notes, uuid: PRESENTATION.id.uuid }, next: { text: nxt.text, notes: nxt.notes } }; } function announcementDoc() { return { announcement_index: null }; } function pushAll() { for (const s of streams) { const doc = s.kind === 'status' ? statusSlideDoc() : s.kind === 'pres' ? slideIndexDoc() : announcementDoc(); try { s.res.write(JSON.stringify(doc) + '\r\n\r\n'); eventsSent++; } catch (_) {} } const cur = FLAT[index] || { text: '', notes: '' }; const fv = JSON.stringify({ acn: 'fv', ary: [ { acn: 'cs', uid: 'slide-' + index, txt: cur.text }, { acn: 'ns', uid: 'slide-' + (index + 1), txt: (FLAT[index + 1] || {}).text || '' }, { acn: 'csn', txt: cur.notes }, { acn: 'nsn', txt: (FLAT[index + 1] || {}).notes || '' } ] }); for (const sock of wsClients) { try { sock.write(wsFrame(fv)); } catch (_) {} } console.log(`[MOCK] slide ${index + 1}/${FLAT.length}: ${JSON.stringify(cur.label || cur.notes || cur.text.slice(0, 40))}`); } function goTo(n) { index = Math.max(0, Math.min(FLAT.length - 1, n)); pushAll(); } function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify(obj)); } const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://localhost'); const p = u.pathname; const chunked = u.searchParams.get('chunked') === 'true'; if (p === '/version') return json(res, 200, { name: 'ProPresenter', platform: 'win', os_version: '11', host_description: 'MOCK-PC', api_version: 'v1' }); const streamOrOnce = (kind, doc) => { if (!chunked) return json(res, 200, doc()); res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked', 'Cache-Control': 'no-cache' }); res.write(JSON.stringify(doc()) + '\r\n\r\n'); const rec = { res, kind }; streams.add(rec); req.on('close', () => streams.delete(rec)); }; if (p === '/v1/status/slide') return streamOrOnce('status', statusSlideDoc); if (p === '/v1/presentation/slide_index') return streamOrOnce('pres', slideIndexDoc); if (p === '/v1/announcement/slide_index') return streamOrOnce('ann', announcementDoc); if (p === '/v1/presentation/active' || p === '/v1/presentation/' + PRESENTATION.id.uuid) return json(res, 200, { presentation: PRESENTATION }); if (p === '/v1/announcement/active') return json(res, 200, { announcement: null }); if (p === '/mock/next') { goTo(index + 1); return json(res, 200, { index }); } if (p === '/mock/prev') { goTo(index - 1); return json(res, 200, { index }); } if (p.startsWith('/mock/goto/')) { goTo(parseInt(p.split('/').pop(), 10) - 1); return json(res, 200, { index }); } if (p === '/mock/state') return json(res, 200, { index, total: FLAT.length, streams: streams.size, ws: wsClients.size, eventsSent, slide: FLAT[index] }); if (p === '/') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(`