First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
/*
|
|
* 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();
|
|
})();
|