/* * remote-state.js — pushes a tiny live snapshot of the app's UI state to the local server so the * phone remote can mirror it (real tab list in order, active tab, active tool, splash + red-letter * state). Isolated + additive; POSTs only when something changed (plus a 5s heartbeat). */ (function () { 'use strict'; /* * Is this window a PASSIVE copy rather than the stage renderer? Two cases: the admin console's * private copy (`/?admin=1`) and the booth follower screen (`/?follower=1`). Neither may publish * its own UI as the stage state — the phone remote would then mirror the wrong machine. */ function isOffstageCopy() { try { return /(^|[?&])admin=1(&|$)/.test(window.location.search) || !!window.__adminOffstage || /(^|[?&])follower=1(&|$)/.test(window.location.search) || !!window.__followerMode; } catch (_) { return false; } } var lastJSON = ''; var lastSent = 0; function modeLabel(mode) { var map = { bible: 'Bible', announcements: 'Announce', graphics: 'Graphics', maps: 'Maps', whiteboard: 'Whiteboard', notes: 'Notes', prayer: 'Prayer', ask: 'Ask (AI)', media: 'Media', alpha: 'Alpha', ppsync: 'PP Sync' }; return map[mode] || (mode ? mode.charAt(0).toUpperCase() + mode.slice(1) : 'Tab'); } function round4(v) { return Math.round(v * 10000) / 10000; } /* * What the phone's TV view needs to draw an honest local preview: the real colours and stroke * widths of each tool, plus where the text area sits in the window. Everything is NORMALISED * (sizes as a fraction of window height, the rect as fractions of the window), because the phone * never learns the TV's pixel size — the same numbers work on any screen. * Width formulas mirror getCurrentDrawingSettings() in app.js (highlight = 1.5x the text font, * eraser = 1.3x the highlight); they are duplicated here because that function is module-scoped. */ function inkAndRect(app) { var out = { ink: null, rect: null }; try { var innerW = window.innerWidth || 1; var innerH = window.innerHeight || 1; var ra = document.getElementById('readingArea' + app.activeTabId); var fontSize = 24; try { var textEl = ra ? ra.querySelector('.bible-text') : null; if (textEl) fontSize = parseInt(window.getComputedStyle(textEl).fontSize) || 24; } catch (_) {} var hlSize = Math.max(fontSize * 1.5, 20); var eraseSize = Math.max(hlSize * 1.3, 24); var penSize = Number(window.penSize) || 4; out.ink = { pen: { color: window.penColor || '#ffff00', size: round4(penSize / innerH) }, hl: { color: window.highlighterColor || '#ffff00', size: round4(hlSize / innerH) }, erase: { size: round4(eraseSize / innerH) } }; if (ra) { var b = ra.getBoundingClientRect(); if (b.width > 0 && b.height > 0) { /* * The rect is "the part of the picture that MOVES when the page scrolls". The chapter header * (and any other sticky band) lives INSIDE the reading area but stays put on screen, so it * must be excluded — the phone shifts everything inside this rect during a scroll, and * including the header made it visibly ride along with the finger and snap back. */ var top = b.top; try { var bands = ra.querySelectorAll('.chapter-header, .translation-bar'); for (var i = 0; i < bands.length; i++) { var cs = window.getComputedStyle(bands[i]); if (cs.display === 'none' || (cs.position !== 'sticky' && cs.position !== 'fixed')) continue; var eb = bands[i].getBoundingClientRect(); if (eb.height > 0 && eb.top <= top + 4 && eb.bottom > top) top = eb.bottom; } } catch (_) {} if (b.bottom - top > 10) { out.rect = { x: round4(b.left / innerW), y: round4(top / innerH), w: round4(b.width / innerW), h: round4((b.bottom - top) / innerH) }; } } } } catch (_) {} return out; } function snapshot() { var app = window.bibleApp; if (!app || !app.tabs) return null; var tabs = []; app.tabs.forEach(function (t) { try { if (typeof app.isTabVisible === 'function' && !app.isTabVisible(t)) return; } catch (_) {} var title = modeLabel(t.mode); if (t.mode === 'bible' && t.book) title = t.book + ' ' + (t.chapter || 1); if (t.ppsync && t.book) title = 'PP Sync: ' + t.book + ' ' + (t.chapter || 1); /* * book/chapter/verse/translation are structured fields, not just the display title. The admin * console needs them to (a) clone the live TV state into its private copy exactly and (b) diff * its copy against the TV so it can push ONE item instead of overwriting everything. Parsing * them back out of `title` would lose the verse and the translation entirely. * Additive — the phone remote reads only id/mode/title/active and ignores these. */ tabs.push({ id: String(t.id), mode: t.mode, title: title, active: t.id === app.activeTabId, book: t.book || '', chapter: t.chapter || null, verse: t.verse || null, verseEnd: t.verseEnd || null, translation: t.translation || '' }); }); var tool = null; try { if (window.presentationTools) tool = window.presentationTools.getMode(); } catch (_) {} if (!tool) tool = window.currentTool || 'pen'; var splash = false; try { var ov = document.querySelector('.splash-overlay'); splash = !!(ov && !ov.classList.contains('hidden')); } catch (_) {} var redLetter = false; try { if (window.redLetter && typeof window.redLetter.isEnabled === 'function') redLetter = !!window.redLetter.isEnabled(); } catch (_) {} var extra = inkAndRect(app); return { tabs: tabs, tool: tool, splash: splash, redLetter: redLetter, ink: extra.ink, rect: extra.rect }; } function tick() { try { var s = snapshot(); if (!s) return; var j = JSON.stringify(s); var nowT = Date.now(); if (j === lastJSON && (nowT - lastSent) < 5000) return; // unchanged; heartbeat every 5s anyway lastJSON = j; lastSent = nowT; fetch('/api/remote/app-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: j }).catch(function () {}); } catch (_) {} } /* * Published so the admin console can read its OWN copy's state with exactly the same shape it * reads the TV's from /api/admin/live-state. Diffing two different shapes is how a "difference" * that isn't one gets pushed to the stage, so both sides come from this one function. */ try { window.__remoteSnapshot = snapshot; } catch (_) {} /* * The admin console's private copy must NEVER publish to /api/remote/app-state. * * That snapshot is what the phone remote treats as "what the TV is showing". The endpoint is * loopback-only, which stops a LAN browser — but an admin working ON the TV machine reaches it * over localhost and would pass that check, overwriting the real stage state with their offstage * copy. The phone would then highlight the wrong tab and the admin's private prep would show up * as the operator's live state. Snapshot stays available above; only the publishing stops. */ if (isOffstageCopy()) return; /* * 250ms, not 1s. The phone's highlight lagged the TV by up to ~2.5s (1s here + the phone's own poll), * long enough that the operator tapped a second time thinking the first had missed. snapshot() is a * cheap map over the tab list and this still only POSTs when something actually changed, so the extra * ticks cost nothing on the wire. */ setInterval(tick, 250); })();