Files
bible-app/remote-state.js
T

161 lines
7.6 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* 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);
})();