161 lines
7.6 KiB
JavaScript
161 lines
7.6 KiB
JavaScript
/*
|
|||
|
|
* 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);
|
||
|
|
})();
|