Files

313 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* follower-publish.js — the stage TV's side of the follower link. Isolated + additive.
*
* Publishes what the booth screen needs to redraw this app's picture itself: which tab, which
* passage, where it is scrolled, and every pen stroke as vectors. No pixels. See follower-link.js
* for why (a few KB/s of JSON instead of ~250 Mbit/s of video, over Wi-Fi, with no cable added).
*
* Three rates, because they have three different jobs:
* snapshot 250ms / on change the whole state; retained by the relay so a screen that joins or
* reconnects mid-service is correct within one frame
* scroll per animation frame while scrolling — coarse scroll makes text visibly lurch
* live per animation frame while drawing — the in-progress stroke
*
* COMMIT IS ATOMIC. When a stroke finishes, the committed stroke list and the "clear the live
* overlay" instruction go in ONE message, so the follower can never paint a frame with the stroke on
* neither canvas (the flash) or on both (a doubled, glowing highlighter line). This is the same trap
* the phone mirror hit — see the ink-handshake note in the phone-remote-mirror memory.
*
* This window must be the STAGE renderer. A follower or the admin console's offstage copy publishing
* its own state would fight the real TV for the feed, so both are refused below.
*/
(function () {
'use strict';
var URL_PATH = '/follower-ws?role=publisher';
var SNAPSHOT_MS = 250;
var RECONNECT_MS = 2000;
function isPassiveCopy() {
try {
var q = window.location.search || '';
return /(^|[?&])follower=1(&|$)/.test(q) || /(^|[?&])admin=1(&|$)/.test(q) ||
!!window.__followerMode || !!window.__adminOffstage;
} catch (_) { return false; }
}
/*
* Only the real stage renderer publishes: the Electron window (it alone has the preload bridge).
* Any plain browser that opens the stage URL — the admin peeking from a laptop, a booth screen, a
* test tab left open — must never become a publisher: two publishers alternate as each reconnects
* and the follower flips between their states (seen live: the booth screen bounced between the
* sermon text and a stray tab's splash). The relay refuses non-local publishers too; this stops the
* same mistake on the stage machine itself. `?publisher=1` is the explicit dev override.
*/
function isStageRenderer() {
try {
if (/(^|[?&])publisher=1(&|$)/.test(window.location.search || '')) return true;
return !!(window.electron && window.electron.ipcRenderer);
} catch (_) { return false; }
}
var S = {
ws: null, retry: null, open: false,
lastSnapshot: '', // dedup: only publish when something actually changed
strokeFingerprint: '', // cheap "have the committed strokes changed?" test
lastScroll: null,
lastKey: null, // the passage whose stroke list was last sent (see buildSnapshot)
liveActive: false,
rafId: null, lastPumpAt: 0, lastZoom: ''
};
function app() { return window.bibleApp || null; }
function bridge() { return window.__followerBridge || null; }
function activeTab() {
var a = app();
if (!a || !a.tabs) return null;
for (var i = 0; i < a.tabs.length; i++) if (a.tabs[i].id === a.activeTabId) return a.tabs[i];
return null;
}
function log() { try { console.log.apply(console, ['[FOLLOWER-PUB]'].concat([].slice.call(arguments))); } catch (_) {} }
function send(obj) {
if (!S.ws || S.ws.readyState !== 1) return false;
try { S.ws.send(JSON.stringify(obj)); return true; } catch (_) { return false; }
}
function connect() {
if (S.ws) { try { S.ws.close(); } catch (_) {} S.ws = null; }
// `stage=1` marks the real Electron window; the relay refuses unmarked publishers while it is
// connected, so an old cached browser copy can never take over the feed (see follower-link.js).
var isElectron = !!(window.electron && window.electron.ipcRenderer);
var url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + URL_PATH + (isElectron ? '&stage=1' : '');
var ws;
try { ws = new WebSocket(url); } catch (e) { scheduleRetry(); return; }
S.ws = ws;
ws.onopen = function () {
S.open = true;
S.lastSnapshot = ''; // force a full republish so a reconnecting relay is repopulated
S.strokeFingerprint = '';
S.lastKey = null;
log('connected');
tick();
};
ws.onclose = function () { S.open = false; scheduleRetry(); };
ws.onerror = function () { try { ws.close(); } catch (_) {} };
}
function scheduleRetry() {
if (S.retry) return;
S.retry = setTimeout(function () { S.retry = null; connect(); }, RECONNECT_MS);
}
/*
* Cheap test for "did the committed strokes change?". Comparing the whole list every 250ms would
* mean serialising every point of every stroke on the stage machine forever; this touches only the
* per-stroke arrays' lengths. It catches a new stroke, an undo, a redo and a clear — every way the
* list actually changes in this app.
*/
function fingerprint(strokes) {
var total = 0;
for (var i = 0; i < strokes.length; i++) total += (strokes[i].points ? strokes[i].points.length : 0);
var last = strokes.length ? strokes[strokes.length - 1] : null;
var tail = '';
if (last && last.points && last.points.length) {
var p = last.points[last.points.length - 1];
tail = p.x + ',' + p.y;
}
return strokes.length + ':' + total + ':' + tail;
}
/*
* The stage window's layout, which the booth screen copies exactly.
*
* A window that is minimised, hidden or still starting reports a zero size. Publishing that would
* tell the follower to lay the app out at 0x0 — the text would reflow to nothing and every stroke
* would be misplaced — so the last good geometry is kept instead. Real geometry changes (a resolution
* change, the TV rotating) still come through the moment the window reports a size again.
*/
var lastGoodGeom = null;
function geometry() {
var content = document.querySelector('.tab-content');
var r = content ? content.getBoundingClientRect() : null;
/*
* A media tab pins .tab-content to the viewport (position:fixed; top:0 — its scroll lock), so its rect
* starts at y=0, under the toolbar. The booth must show only what is visible BELOW the toolbar on the
* TV, or the feed grows a black band (the follower's hidden toolbar) and changes size on every switch
* to and from that tab. Clamp the published region to below the toolbar.
*/
if (r) {
var tb = document.querySelector('.toolbar');
var tbr = tb ? tb.getBoundingClientRect() : null;
if (tbr && tbr.height > 0 && r.y < tbr.bottom) r = { x: r.x, y: tbr.bottom, width: r.width, height: Math.max(1, r.bottom - tbr.bottom) };
}
var g = {
w: window.innerWidth,
h: window.innerHeight,
dpr: window.devicePixelRatio || 1,
// The region the booth actually shows: everything below the toolbar and tab strip.
content: r ? { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) } : null
};
if (!g.w || !g.h || !g.content || !g.content.w || !g.content.h) return lastGoodGeom;
lastGoodGeom = g;
return g;
}
function splashVisible() {
try {
var ov = document.getElementById('splashOverlay') || document.querySelector('.splash-overlay');
if (!ov) return false;
if (ov.classList && ov.classList.contains('hidden')) return false;
var cs = window.getComputedStyle(ov);
return cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) > 0.05;
} catch (_) { return false; }
}
function buildSnapshot() {
var a = app(), t = activeTab(), b = bridge();
if (!a || !t) return null;
var ra = document.getElementById('readingArea' + t.id);
var key = b ? b.passageKey(t.id) : null;
var strokes = (b && key) ? b.strokes(key) : [];
var fp = fingerprint(strokes);
/*
* The list rides along when it changed, when the PASSAGE changed, or on a forced full republish
* (fresh connection, right after a commit, or a bulk change — see window.__followerPublish).
* The passage test matters: after a clear-all every passage's list is empty, so two passages share
* one fingerprint — without it the follower kept the ink it had cached for the passage the stage
* switched to, and the program feed showed highlights the pastor had already wiped.
*/
var strokesChanged = fp !== S.strokeFingerprint || key !== S.lastKey || S.lastSnapshot === '';
var snap = {
type: 'snapshot',
ts: Date.now(),
tab: {
id: t.id, mode: t.mode, ppsync: !!t.ppsync,
book: t.book || '', chapter: t.chapter || null, verse: t.verse || null, verseEnd: t.verseEnd || null,
translation: t.translation || 'ESV', loaded: !!t.loaded, mediaTitle: t.mediaTitle || null
},
scrollTop: ra ? Math.round(ra.scrollTop) : 0,
geom: geometry(),
splash: splashVisible(),
passageKey: key,
strokeFp: fp
};
// Only carry the stroke list when it actually changed — otherwise the follower keeps what it has.
if (strokesChanged) { snap.strokes = strokes; S.strokeFingerprint = fp; S.lastKey = key; }
/*
* Whatever else this particular tab is showing. Each is small and only present for the tab it
* belongs to, so the snapshot stays a few hundred bytes on a Bible passage.
*/
if (t.mode === 'graphics' || t.mode === 'maps' || t.mode === 'announcements') {
var car = b ? b.carousel(t.id) : null;
// The URL rides along so the follower can match the actual picture, not just a position in a
// list the two machines built independently.
if (car) snap.carousel = { index: car.index, count: car.count, url: car.urlAt(car.index), zoom: car.zoom || null };
} else if (t.mode === 'prayer') {
var html = b ? b.prayerHtml(t.id) : null;
if (html != null) snap.prayer = html;
} else if (t.mode === 'ask') {
snap.ask = b ? b.askMessages(t.id) : [];
}
// The notes tab tracks which point it is on; harmless elsewhere.
if (typeof t.notesCurrentIndex === 'number') snap.notesIndex = t.notesCurrentIndex;
return snap;
}
function tick() {
if (!S.open) return;
var snap = buildSnapshot();
if (!snap) return;
// Compare without the timestamp, or every tick looks like a change.
var ts = snap.ts; snap.ts = 0;
var json = JSON.stringify(snap);
if (json === S.lastSnapshot) return;
S.lastSnapshot = json;
snap.ts = ts;
send(snap);
}
// ------------------------------------------------------------------ high-rate: scroll + live ink
function pumpFrame() {
S.rafId = null;
S.lastPumpAt = Date.now();
if (!S.open) return;
var t = activeTab(), b = bridge();
if (!t || !b) return;
var ra = document.getElementById('readingArea' + t.id);
if (ra) {
var top = Math.round(ra.scrollTop);
if (top !== S.lastScroll) { S.lastScroll = top; send({ type: 'scroll', tabId: t.id, scrollTop: top }); }
}
// Maps pinch-zoom / pan, per frame while it changes (a pinch is as continuous as a stroke).
var car = b.carousel(t.id);
if (car && car.zoom) {
var z = car.zoom;
var zs = z.s + ',' + z.x + ',' + z.y;
if (zs !== S.lastZoom) { S.lastZoom = zs; send({ type: 'zoom', tabId: t.id, zoom: z }); schedule(); }
}
var live = b.live(t.id);
if (live) { S.liveActive = true; send({ type: 'live', tabId: t.id, live: live }); schedule(); }
else if (S.liveActive) {
/*
* The stroke ended. Send the committed list and the overlay clear as ONE message so the
* follower swaps them in a single paint — see the atomic-commit note at the top.
*/
S.liveActive = false;
var key = b.passageKey(t.id);
var strokes = key ? b.strokes(key) : [];
S.strokeFingerprint = fingerprint(strokes);
S.lastKey = key;
send({ type: 'commit', tabId: t.id, passageKey: key, strokes: strokes });
S.lastSnapshot = ''; // the next tick republishes so the retained snapshot carries the stroke
}
}
function schedule() {
if (S.rafId != null) return;
S.rafId = requestAnimationFrame(pumpFrame);
}
/*
* The floor under requestAnimationFrame. rAF stops entirely when the window is occluded, minimised
* or (in an embedded browser view) simply not the front tab — and a pump that only ran on rAF would
* then stop streaming ink and scroll to the booth with no error anywhere. This ticks the same pump
* on a timer whenever rAF has not run recently, so the follower keeps up at ~30fps in the worst case
* and at display rate normally. Never make the live path depend on rAF alone.
*/
function pumpFloor() {
if (Date.now() - S.lastPumpAt < 28) return; // rAF is running; leave it alone
if (S.rafId != null) { try { cancelAnimationFrame(S.rafId); } catch (_) {} S.rafId = null; }
pumpFrame();
}
/*
* Bulk ink changes on the stage — clear-all (trash held) and its undo/redo — touch passages that are
* NOT on screen, so no snapshot would ever carry their new (empty or restored) lists, and the follower
* kept showing highlights the pastor had wiped. app.js calls these at every bulk mutation:
* clearAll() — the follower and the relay drop EVERY cached list right now;
* invalidate() — the next snapshot carries the active passage's list again, and every later passage
* switch carries its own (see buildSnapshot).
* Defined on every copy of the app; both are no-ops while this copy is not publishing.
*/
window.__followerPublish = {
invalidate: function () { S.strokeFingerprint = ''; S.lastKey = null; S.lastSnapshot = ''; if (S.open) { try { tick(); } catch (_) {} } },
clearAll: function () { if (S.open) send({ type: 'clearAll', ts: Date.now() }); this.invalidate(); }
};
function boot() {
if (isPassiveCopy()) { log('offstage/follower copy — not publishing'); return; }
if (!isStageRenderer()) { log('plain browser copy — not publishing (only the Electron stage window does; add ?publisher=1 to force)'); return; }
if (typeof WebSocket !== 'function') { log('no WebSocket in this runtime'); return; }
connect();
setInterval(tick, SNAPSHOT_MS);
// The pump: rAF while the window is live, the timer floor whenever it is not (see pumpFloor).
setInterval(pumpFloor, 33);
log('ready');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();