Files
bible-app/follower-view.js
T

394 lines
20 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* follower-view.js — the booth screen. Isolated + additive; loaded ONLY for index.html?follower=1.
*
* This page is the real app, not a lookalike. It receives state from the stage TV (follower-link.js)
* and drives its own renderer to the same passage, scroll and strokes, then hands the picture to
* ProPresenter over the machine's existing HDMI output.
*
* WHY THE REAL APP: the pen strokes are positioned against the text. A second implementation of the
* Bible view would wrap lines even slightly differently and every underline would sit under the wrong
* word. Running the same renderer at the same CSS geometry makes that impossible by construction.
*
* WHY THE TOOLBAR IS HIDDEN BUT NOT REMOVED: the operator wants only the content area on screen. The
* obvious `display:none` would give the content the toolbar's height back, reflowing the text — and
* every stroke would then land a line off. Instead the layout is kept EXACTLY as the stage TV's and
* the chrome is scrolled out of frame and clipped. Never "simplify" this by removing the toolbar.
*
* Geometry: the app is forced to the stage TV's CSS size (published in every snapshot), then scaled
* with one CSS transform to fill this display, letterboxed on black. Text, images and ink scale
* together, so a 1080p booth output and a 4K portrait stage stay pixel-faithful to each other.
*/
(function () {
'use strict';
/*
* Hard gate. This file ships in the same index.html the stage TV loads, and everything below —
* hiding the toolbar, disabling input, driving the passage from the network — would wreck the live
* screen. Only ?follower=1 turns it on.
*/
function isFollower() {
try { return !!window.__followerMode || /(^|[?&])follower=1(&|$)/.test(window.location.search); }
catch (_) { return false; }
}
if (!isFollower()) return;
var RECONNECT_MS = 1500;
var S = {
ws: null, retry: null,
geom: null, // stage geometry, from the snapshot
tabKey: '', // active passage identity, so we only reload on a real change
pendingScroll: null,
passageKey: null,
splash: null, // mirrored from the stage, never decided locally
carousel: null, // the image the stage is showing
pendingCarousel: null,
lastSeq: -1,
connected: false,
stats: { snapshots: 0, commits: 0, live: 0, scrolls: 0, lastMsgAt: 0 }
};
function app() { return window.bibleApp || null; }
function bridge() { return window.__followerBridge || null; }
function log() { try { console.log.apply(console, ['[FOLLOWER]'].concat([].slice.call(arguments))); } catch (_) {} }
// ------------------------------------------------------------------ chrome + geometry
function styleOnce() {
if (document.getElementById('followerStyle')) return;
var css = [
/* Black everywhere the page does not reach. */
'html,body{background:#000!important;margin:0!important;padding:0!important;overflow:hidden!important;cursor:none!important;}',
'#followerStage{position:fixed;left:0;top:0;width:100vw;height:100vh;background:#000;overflow:hidden;z-index:0;}',
/* The app is laid out at the STAGE TV's size, then scaled as one piece. */
'#app{position:absolute!important;left:0!important;top:0!important;transform-origin:0 0!important;}',
/* Nothing on this screen is interactive — it is a picture going to a switcher. The descendant
rule matters: the annotation canvases set pointer-events:auto INLINE, which beats the value they
would inherit from #app, so a mouse on the booth machine could draw ink here that nothing could
erase. An !important author rule outranks the inline declaration. */
'#app,#app *{pointer-events:none!important;}',
/* Chrome stays in the layout (so text wraps identically) but must never be seen. */
'.toolbar{visibility:hidden!important;}',
/* The chapter header's navigation cluster (back, up/down, -5/+5): a program feed shows the title
only. visibility, not display — the header is a 3-column grid whose right spacer mirrors this
cluster's width to keep the title centred; collapsing it would shift the title. */
'.chapter-header .chapter-left-controls,.chapter-header .header-back-btn,.chapter-header .header-vertical-group,.chapter-header .chapter-right-controls{visibility:hidden!important;}',
/* Overlays that would be wrong on a program feed. */
'#offstageBadge,.touch-menu-overlay,.qd-dock,#toolRail,#toolIndicator{display:none!important;}',
/* The splash occupies EXACTLY the box an announcement card does: from the top of the content region,
100vh tall (the card runs on under the toolbar's height at the bottom, as announcements do), with
the stock contain fit — so a splash of ANY aspect lands where the same picture would land as an
announcement, and the shell fits ONE box always. (A 'cover' fit would have zoomed the church's 2:3
splash 15% and cropped its sides — a size change, the very thing this exists to remove.) The var
comes from the stage geometry (applyGeometry); until it arrives it falls back to the viewport. */
'.splash-overlay{top:var(--fw-content-top,0px)!important;height:100vh!important;bottom:auto!important;}',
/* The toolbar band's height is decided by CSS the two machines evaluate separately: @media
(pointer: coarse) makes the TV's tabs 44px (band ~56px) while a mouse-driven booth lays them out
at 38px (band 50px). The band stays in the layout here (see above), so its height must be the
STAGE's, or every line of text and every stroke sits 6px off and the crop takes the top of the
page. Same var as the splash box. */
'.toolbar{height:var(--fw-content-top,auto)!important;min-height:0!important;max-height:none!important;overflow:hidden!important;}',
/* Until the first snapshot has said what is on the TV, show nothing: init.js creates the splash
visible, and before the geometry arrives it would render at THIS window's aspect and then jump
into the portrait box (on every connect and every version-change reload). */
'html:not(.fw-ready) .splash-overlay{visibility:hidden!important;opacity:0!important;transition:none!important;}',
/* The Ask panel is a faintly blue near-black (#0c0e12) on the TV; against this screen's pure-black
letterbox that tint reads as a panel edge on the program feed. Black here, and only here. */
'.ask-reading-area{background:#000!important;}'
].join('\n');
var el = document.createElement('style');
el.id = 'followerStyle';
el.textContent = css;
document.head.appendChild(el);
var stage = document.createElement('div');
stage.id = 'followerStage';
document.body.insertBefore(stage, document.body.firstChild);
}
/*
* Force this window to lay out exactly like the stage TV, then scale to fit and slide the chrome
* off the top. `content` is the stage's .tab-content rect, so what remains on screen is precisely
* the region the operator asked for — the Bible text, graphics and announcements, nothing else.
*/
/*
* FRAMED (the normal case — follower.html): this page runs inside an iframe that the shell sizes to
* the stage's exact viewport, so every viewport unit already resolves as on the TV and nothing here
* may be transformed. The geometry is simply reported up to the shell, which does the fit-and-crop.
* UNFRAMED (the page opened directly): fall back to transforming #app — layout parity is then only
* approximate (100vh is this window's, not the stage's), which is why the shell is the supported way.
*/
var framed = false;
try { framed = window.parent && window.parent !== window && /(^|[?&])framed=1(&|$)/.test(window.location.search); } catch (_) { framed = false; }
function applyGeometry(g) {
if (!g || !g.w || !g.h) return;
var content = g.content || { x: 0, y: 0, w: g.w, h: g.h };
if (framed) {
/*
* The splash is confined to the CONTENT box (CSS vars read by the rule in styleOnce), so it occupies
* exactly the region the announcements do and the shell fits ONE box always. Fitting the whole frame
* while the splash was up made the splash 2.6% smaller than every other tab on the program feed —
* and swapping boxes was itself a visible size change.
*/
try {
if (S.fwTop !== content.y) { // guarded: applyGeometry re-runs every 500ms
document.documentElement.style.setProperty('--fw-content-top', content.y + 'px');
S.fwTop = content.y;
}
} catch (_) {}
try { window.parent.postMessage({ type: 'follower-geom', geom: g, splash: !!S.splash }, window.location.origin); } catch (_) {}
S.appliedGeom = { w: g.w, h: g.h, cw: content.w, ch: content.h, framed: true, inner: [window.innerWidth, window.innerHeight] };
return;
}
var appEl = document.getElementById('app');
if (!appEl) return;
appEl.style.width = g.w + 'px';
appEl.style.height = g.h + 'px';
var vw = window.innerWidth, vh = window.innerHeight;
var scale = Math.min(vw / content.w, vh / content.h); // fit the CONTENT region, not the whole app
var offsetX = (vw - content.w * scale) / 2 - content.x * scale;
var offsetY = (vh - content.h * scale) / 2 - content.y * scale;
appEl.style.transform = 'translate(' + offsetX + 'px,' + offsetY + 'px) scale(' + scale + ')';
S.appliedGeom = { w: g.w, h: g.h, cw: content.w, ch: content.h, scale: scale, framed: false };
}
// ------------------------------------------------------------------ state application
function tabIdentity(t) {
return [t.mode, t.ppsync ? 1 : 0, t.book, t.chapter, t.verse, t.verseEnd, t.translation].join('|');
}
/*
* Move this app to the stage's tab and passage. The follower has the same startup tab set, so the
* matching tab is found by mode (and the ppsync flag), never by id — ids are timestamps and differ
* between machines.
*/
function applyTab(remote) {
var a = app();
if (!a || !a.tabs) return null;
var local = null;
for (var i = 0; i < a.tabs.length; i++) {
var t = a.tabs[i];
if (t.mode !== remote.mode) continue;
if (!!t.ppsync !== !!remote.ppsync) continue;
local = t; break;
}
if (!local) return null;
if (a.activeTabId !== local.id) { try { a.switchTab(local.id); } catch (_) {} }
var identity = tabIdentity(remote);
if (identity !== S.tabKey) {
S.tabKey = identity;
if (remote.mode === 'bible' && remote.book && remote.chapter) {
local.book = remote.book;
local.chapter = remote.chapter;
local.verse = remote.verse || 1;
local.verseEnd = remote.verseEnd || null;
local.translation = remote.translation || local.translation;
local.loaded = false;
local.savedText = null;
local.savedHeader = null;
local._scrollToVerse = null; // scroll is mirrored from the stage, never chosen here
try { a.loadPassage(local.id); } catch (e) { log('loadPassage failed', e); }
}
}
return local;
}
function applyScroll(tabId, top) {
var ra = document.getElementById('readingArea' + tabId);
if (!ra) { S.pendingScroll = { tabId: tabId, top: top }; return; }
if (Math.abs(ra.scrollTop - top) > 0.5) ra.scrollTop = top;
}
/*
* The splash is mirrored, never decided here. init.js refuses every show/hide on the follower that does
* not carry the token below: in 1.4.29 the follower's own idle timer was still being armed by mouse
* movement on the booth Mac and by ProPresenter slides (propresenter-sync.js resets it on every slide),
* so the program feed went to a splash of its own — fitted to the content box, hence the two sizes —
* and came back on the next slide. The link client itself no longer runs on the follower either.
*/
function applySplash(on) {
try {
if (on === S.splash) return;
S.splash = on;
if (on) { if (typeof window.showSplash === 'function') window.showSplash('__follower-mirror'); }
else { if (typeof window.hideSplash === 'function') window.hideSplash('__follower-mirror'); }
} catch (_) {}
}
/*
* The image carousels (Graphics / Maps / Announcements). Matched by URL first: both machines build
* their list independently from the same server, so if a picture is added or reordered mid-service,
* position 3 need not be the same image on each screen — and showing the wrong graphic on the
* program feed is exactly the failure this avoids. The index is the fallback.
*
* Re-asserted on EVERY snapshot rather than only on change: the carousel refuses a move while it is
* still animating, and the follower's own list can finish loading a beat after the stage's, so a
* single attempt can be dropped. Re-asserting makes it self-heal within 250ms, and it is a no-op
* once the two agree.
*/
function applyCarousel(tabId, want) {
if (!want) return;
var b = bridge();
var car = b && b.carousel(tabId);
if (!car || !car.count) { S.pendingCarousel = { tabId: tabId, want: want }; return; }
S.pendingCarousel = null;
var target = -1;
if (want.url) target = car.indexOfUrl(want.url);
if (target < 0 && typeof want.index === 'number') target = Math.min(Math.max(0, want.index), car.count - 1);
if (target >= 0 && target !== car.index) car.goTo(target);
// Pinch-zoom / pan of the current map, from the snapshot (late joiners); live changes arrive as 'zoom'.
if (want.zoom && car.setZoom) car.setZoom(want.zoom);
}
function onZoom(msg) {
var b = bridge(), id = activeLocalTabId();
var car = b && id && b.carousel(id);
if (car && car.setZoom) car.setZoom(msg.zoom || { s: 1, x: 0, y: 0 });
}
function onSnapshot(msg) {
S.stats.snapshots++;
if (typeof msg.splash === 'boolean') applySplash(msg.splash);
// First word from the stage: from here on the splash may be shown (only ever as mirrored).
try { if (!S.ready) { S.ready = true; document.documentElement.classList.add('fw-ready'); } } catch (_) {}
if (msg.geom) { S.geom = msg.geom; applyGeometry(msg.geom); }
var local = applyTab(msg.tab);
if (!local) return;
if (typeof msg.scrollTop === 'number') applyScroll(local.id, msg.scrollTop);
// `strokes` is present only when the stage's list changed (bandwidth), so absence means "keep".
if (msg.passageKey) S.passageKey = msg.passageKey;
var b = bridge();
if (msg.strokes && b) b.setStrokes(local.id, msg.passageKey, msg.strokes);
// Whatever else this tab shows.
S.carousel = msg.carousel || null;
if (msg.carousel) applyCarousel(local.id, msg.carousel);
if (typeof msg.prayer === 'string' && b) b.setPrayerHtml(local.id, msg.prayer);
if (msg.ask && b) b.setAskMessages(local.id, msg.ask);
}
function activeLocalTabId() {
var a = app();
return a ? a.activeTabId : null;
}
var lastGeomCheck = 0;
function checkCanvases(force) {
var now = Date.now();
if (!force && now - lastGeomCheck < 250) return;
lastGeomCheck = now;
try { var b = bridge(), id = activeLocalTabId(); if (b && id && b.ensureCanvasGeometry) b.ensureCanvasGeometry(id); } catch (_) {}
}
function onLive(msg) {
S.stats.live++;
checkCanvases(false);
var b = bridge(), id = activeLocalTabId();
if (b && id) b.paintLive(id, msg.live);
}
// Committed strokes and the overlay clear arrive together and are applied together — one paint,
// so the line is never on both canvases (doubled) or neither (a flash).
function onCommit(msg) {
S.stats.commits++;
var b = bridge(), id = activeLocalTabId();
if (!b || !id) return;
if (msg.passageKey) { S.passageKey = msg.passageKey; b.setStrokes(id, msg.passageKey, msg.strokes || []); }
b.paintLive(id, null);
}
// The stage wiped every passage's ink (trash held). Passages not on screen never get a snapshot, so
// their cached lists would have come back the next time the stage showed them — drop them all now.
function onClearAll() {
S.stats.clears = (S.stats.clears || 0) + 1;
var b = bridge(), id = activeLocalTabId();
if (b && b.clearAllInk) b.clearAllInk(id);
}
function onMessage(raw) {
var msg;
try { msg = JSON.parse(raw); } catch (_) { return; }
if (!msg || !msg.type) return;
S.stats.lastMsgAt = Date.now();
if (msg.type === 'clearAll') return onClearAll();
if (msg.type === 'snapshot') return onSnapshot(msg);
if (msg.type === 'live') return onLive(msg);
if (msg.type === 'commit') return onCommit(msg);
if (msg.type === 'scroll') { S.stats.scrolls++; return applyScroll(activeLocalTabId(), msg.scrollTop); }
if (msg.type === 'zoom') return onZoom(msg);
}
// ------------------------------------------------------------------ link
function connect() {
if (S.ws) { try { S.ws.close(); } catch (_) {} S.ws = null; }
var url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/follower-ws?role=subscriber';
var ws;
try { ws = new WebSocket(url); } catch (_) { scheduleRetry(); return; }
S.ws = ws;
ws.onopen = function () { S.connected = true; S.tabKey = ''; log('linked to stage'); };
ws.onmessage = function (ev) { onMessage(ev.data); };
ws.onclose = function () { S.connected = 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);
}
/*
* Belt and braces to the CSS above: swallow every kind of input at the window, capture phase, before
* any app handler sees it. Wheel and keys included — a scroll or an Escape on the booth machine would
* otherwise move the page or hide the mirrored splash. The shell reloads this page on version
* change, so nothing here ever needs a keypress.
*/
function blockAllInput() {
var kill = function (e) { try { e.stopImmediatePropagation(); if (e.cancelable) e.preventDefault(); } catch (_) {} };
['pointerdown', 'pointerup', 'pointermove', 'pointercancel', 'mousedown', 'mouseup', 'mousemove', 'click', 'dblclick',
'touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel', 'keydown', 'keyup', 'keypress', 'contextmenu', 'dragstart']
.forEach(function (type) { window.addEventListener(type, kill, { capture: true, passive: false }); });
}
function boot() {
styleOnce();
blockAllInput();
var a = app();
if (!a || !a.tabs || !a.tabs.length || !window.__followerBridge) { setTimeout(boot, 120); return; }
// A late-arriving passage render can land after the scroll we were told to apply.
setInterval(function () {
if (S.pendingScroll) {
var p = S.pendingScroll;
var ra = document.getElementById('readingArea' + p.tabId);
if (ra) { ra.scrollTop = p.top; S.pendingScroll = null; }
}
if (S.geom) applyGeometry(S.geom);
// Canvases built before the frame took the stage's size (or before a display-scaling change) draw
// every stroke scaled toward the top-left; rebuild them the moment their geometry is off.
checkCanvases(true);
// The stage's list is the only truth about ink: anything else on the canvas is replaced.
try { var bb = bridge(), bid = activeLocalTabId(); if (bb && bid && bb.reassert) bb.reassert(bid); } catch (_) {}
// Re-derive the ink if this display's pixel ratio changed under us (window moved to another
// monitor, OS scaling changed). Cheap no-op when it did not.
try {
var b = bridge(), id = activeLocalTabId();
if (b && id) b.reapply(id);
// The carousel element is built a beat after the tab switch, so a picture asked for during
// that gap has to be re-asserted once it exists.
if (S.pendingCarousel) applyCarousel(S.pendingCarousel.tabId, S.pendingCarousel.want);
else if (id && S.carousel) applyCarousel(id, S.carousel);
} catch (_) {}
}, 500);
window.addEventListener('resize', function () { if (S.geom) applyGeometry(S.geom); });
connect();
log('ready — waiting for the stage');
}
window.__follower = {
get connected() { return S.connected; },
get stats() { return S.stats; },
get geom() { return S.appliedGeom; },
_apply: onMessage
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();