/* * 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(); })();