Files
bible-app/browser-tab.js
T
christianandClaude Opus 5.5 e1f541d366 Browser tab: no focus highlight on the page
Every key on the app's keyboard takes focus from the page for a moment and
gives it back, so the site's focus ring (a 2px blue outline on the search
box, and a brighter border) flashed with each letter. Outline rings are
turned off with the page's clean-up CSS, and the guest preload strips
outline, border and box-shadow from the site's own :focus / :focus-within /
:focus-visible rules, keeping anything else those rules do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:28:59 -04:00

825 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* browser-tab.js — the Browser tab: a live web page, full-bleed in the content area, with no browser
* chrome at all (no address bar, bookmarks or scrollbars). Isolated + additive, like quick-dock.js.
*
* WHAT RUNS WHERE. One file, three renderers, picked once at load:
*
* stage the Electron window on the TV. Each Browser tab owns an Electron <webview> — the same
* Chromium engine as Chrome, in its own sandboxed process and its own persistent session
* (persist:browser-tab), so the site's settings and any sign-in survive restarts.
* browser-guest-preload.cjs runs inside the page and talks to this file.
* follower the booth screen (?follower=1). It never loads the site: it shows the stage's copy of the
* page's DOM (scripts stripped) and follows its scroll and form fields. Loading the site
* itself would re-run the search and could show the program feed different results.
* live a plain browser — the admin console's offstage copy, or `npm start` in a desktop browser.
* There is no <webview> there, so the page is a sandboxed iframe of the same address. It
* shows the page; the on-screen keyboard and scripture taps need the stage.
*
* WHY THE PAGES LIVE IN THEIR OWN LAYER. Every tab switch rebuilds .tab-content, and moving or
* re-creating a <webview> reloads its page — the search, the scroll position and anything typed would
* all be lost each time pastor looked at another tab. So the pages live in #browserTabHost, one
* persistent layer inside .tab-content that app.js never clears (clearTabContentKeepingBrowser), laid
* exactly over the content area. Switching tabs only shows or hides it, which is also what keeps a page
* "running in the background": it loads at startup and is ready the first time the tab is opened.
*
* WHAT THE PAGE CANNOT DO ON ITS OWN, and is wired here:
* - the app's on-screen keyboard (the Prayer tab's, reused) types into the focused field of the page;
* - a tapped scripture reference opens in a new Bible tab, in the page's translation when the app has it;
* - touches inside the page reset the idle splash, and five fingers still blank the screen;
* - the phone remote's scroll, tap and page buttons, and OSC / admin reload and home, reach the page;
* - the booth follower gets the page, and the phone mirror gets its scroll position.
*/
(function () {
'use strict';
var KEY_URL = 'browserTabUrl';
var KEY_ZOOM = 'browserTabZoom';
var KEY_TAPS = 'browserTabScriptureTaps';
var SESSION_KEY = 'browserTabSession';
var DEFAULT_URL = 'https://it-is-written-waters-church.vercel.app/';
var PARTITION = 'persist:browser-tab';
var RETRY_MS = 10000;
/*
* The page itself is the only thing on screen: no scrollbars, no blue tap flash, no focus ring, no text
* selection from a resting finger, and no rubber-band pull that would drag the whole page (or, sideways,
* swipe back a page). Inputs keep their selection so typing still behaves. Applied to the live page with
* insertCSS and to the booth's copy as a <style>, so both screens look the same. (The ring matters
* twice: every key on the app's keyboard takes focus from the page for a moment, so a ring would flash
* with each letter. The site's other focus styles are taken out by browser-guest-preload.cjs.)
*/
var CLEAN_CSS =
'html,body{scrollbar-width:none!important;overscroll-behavior:none!important;}' +
'::-webkit-scrollbar{display:none!important;width:0!important;height:0!important;}' +
'*{-webkit-tap-highlight-color:transparent!important;}' +
':focus,:focus-visible{outline:none!important;}' +
'body *{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;}' +
'input,textarea,[contenteditable],[contenteditable] *{-webkit-user-select:text!important;user-select:text!important;}';
function ls(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
function log() { try { console.log.apply(console, ['[BROWSER TAB]'].concat([].slice.call(arguments))); } catch (_) {} }
// ------------------------------------------------------------------ role
function detectRole() {
var q = '';
try { q = window.location.search || ''; } catch (_) {}
if (window.__followerMode || /(^|[?&])follower=1(&|$)/.test(q)) return 'follower';
if (window.__adminOffstage || /(^|[?&])admin=1(&|$)/.test(q)) return 'live';
/*
* The stage is the Electron window — the only one with the preload bridge (the same test
* follower-publish.js uses). Not "is <webview> defined": Electron registers that element after this
* script has already run, so asking here would wrongly put the stage on the plain-browser path.
*/
try { if (window.electron && window.electron.ipcRenderer) return 'stage'; } catch (_) {}
return 'live';
}
function webviewReady() { try { return !!(window.customElements && window.customElements.get('webview')); } catch (_) { return false; } }
var ROLE = detectRole();
// ------------------------------------------------------------------ settings
function startUrl() {
var u = String(ls(KEY_URL) || '').trim();
return /^https?:\/\//i.test(u) ? u : DEFAULT_URL;
}
function tapsOn() { return ls(KEY_TAPS) !== '0'; }
/*
* Page size. The stage is a 4K portrait TV (1080 CSS px across); at 100% a site meant for a laptop is a
* narrow column of small text down the middle. "Auto" lays the page out as if ~540 px wide — a tablet
* — and scales that up to fill the width, which is how a site looks best on a tall, narrow screen.
* A percentage in Settings › Browser Tab overrides it.
*/
function zoomFor(widthPx) {
var z = String(ls(KEY_ZOOM) || 'auto');
if (z !== 'auto') {
var n = parseFloat(z);
if (isFinite(n) && n >= 25 && n <= 500) return n / 100;
}
var w = widthPx || window.innerWidth || 1080;
return Math.max(1, Math.min(2.5, Math.round((w / 540) * 20) / 20));
}
/*
* Where each page was, kept for this window session only: a trip to Settings navigates this window away
* and back, and coming back to the start page would lose the search pastor typed. A fresh app launch
* (or Reset, or changing the start page) begins at the start page again.
*/
function readSession() {
try {
var s = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null');
if (s && s.start === startUrl() && Array.isArray(s.tabs)) return s;
} catch (_) {}
return { start: startUrl(), tabs: [] };
}
var saveTimer = 0;
function saveSessionSoon() {
if (ROLE !== 'stage' || saveTimer) return;
saveTimer = setTimeout(function () {
saveTimer = 0;
try {
var out = { start: startUrl(), tabs: browserTabs().map(function (t) {
var v = views[String(t.id)];
return v ? { url: v.url || null, y: v.scrollY || 0 } : null;
}) };
sessionStorage.setItem(SESSION_KEY, JSON.stringify(out));
} catch (_) {}
}, 400);
}
function resetSession() { restore = null; try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {} }
/*
* Restoring is for the pages that were open when this window loaded (coming back from Settings). It is
* read once, handed out as those tabs get their pages, then dropped: a Browser tab added later from the
* + menu opens the start page rather than inheriting a closed tab's search.
*/
var restore = (ROLE === 'stage') ? readSession().tabs.slice() : null;
// ------------------------------------------------------------------ app access
function app() { return window.bibleApp || null; }
function browserTabs() {
var a = app();
return (a && Array.isArray(a.tabs)) ? a.tabs.filter(function (t) { return t.mode === 'browser'; }) : [];
}
function tabById(id) {
var a = app();
if (!a || !a.tabs) return null;
for (var i = 0; i < a.tabs.length; i++) if (String(a.tabs[i].id) === String(id)) return a.tabs[i];
return null;
}
function activeTab() { var a = app(); return a ? tabById(a.activeTabId) : null; }
function activeView() {
var t = activeTab();
return (t && t.mode === 'browser') ? (views[String(t.id)] || null) : null;
}
// ------------------------------------------------------------------ layer
var styled = false;
function styleOnce() {
if (styled) return;
styled = true;
var st = document.createElement('style');
st.id = 'browserTabStyle';
st.textContent = [
// The layer covers exactly the content area. Measured on every tab type: making .tab-content a
// positioning context moves nothing that is already there.
'.tab-content{position:relative;}',
'#browserTabHost{position:absolute;inset:0;z-index:2;overflow:hidden;background:#000;visibility:hidden;pointer-events:none;}',
'#browserTabHost.bt-on{visibility:visible;pointer-events:auto;}',
/* Hidden views keep their size: a zero-sized page would reflow and lose its place. */
'#browserTabHost .bt-view{position:absolute;left:0;top:0;width:100%;height:100%;border:0;margin:0;padding:0;background:#000;visibility:hidden;}',
'#browserTabHost .bt-view.bt-active{visibility:visible;}',
/* A page paints white before its own background arrives; on a black stage that is a flash. */
'#browserTabHost webview.bt-view{opacity:0;transition:opacity .2s ease;}',
'#browserTabHost webview.bt-view.bt-painted{opacity:1;}',
'#browserTabHost iframe.bt-view{transform-origin:0 0;}',
'#browserTabHost .bt-status{position:absolute;left:0;right:0;top:38%;text-align:center;color:#8a8a8a;' +
'font:500 22px/1.5 Georgia,serif;padding:0 32px;pointer-events:none;visibility:hidden;}',
'#browserTabHost .bt-status.bt-show{visibility:visible;}',
'#browserKeyboard{z-index:20001;}'
].join('\n');
document.head.appendChild(st);
}
function host() {
var tc = document.querySelector('.tab-content');
if (!tc) return null;
styleOnce();
var h = document.getElementById('browserTabHost');
if (!h) {
h = document.createElement('div');
h.id = 'browserTabHost';
h.setAttribute('aria-label', 'Browser tab');
tc.insertBefore(h, tc.firstChild);
}
return h;
}
// ------------------------------------------------------------------ views
var views = Object.create(null); // String(tabId) -> view
function statusEl(v) {
if (v.status) return v.status;
var s = document.createElement('div');
s.className = 'bt-status';
v.status = s;
var h = host();
if (h) h.appendChild(s);
return s;
}
function setStatus(v, text) {
var s = statusEl(v);
s.textContent = text || '';
s.classList.toggle('bt-show', !!text && isShown(v));
}
function isShown(v) { var t = activeTab(); return !!(t && String(t.id) === v.id); }
function create(tab, ordinal) {
var h = host();
if (!h) return null;
// Moments after load <webview> may not be registered yet; sync() comes back for this tab shortly.
if (ROLE === 'stage' && !webviewReady()) { setTimeout(sync, 150); return null; }
var id = String(tab.id);
var sess = restore ? restore[ordinal] : null;
if (restore) restore[ordinal] = null;
var url = (ROLE === 'stage' && sess && sess.url) ? sess.url : startUrl();
var v = {
id: id, kind: null, el: null, url: url, title: '', zoom: 1,
scrollY: 0, scrollMax: 0, vh: 0, pendingScroll: (ROLE === 'stage' && sess && sess.y) ? sess.y : null,
domVer: 0, domHtml: null, inputs: null, ready: false, retry: 0, status: null
};
views[id] = v;
if (ROLE === 'stage') makeWebview(v);
else if (ROLE === 'follower') makeSnapshotFrames(v);
else makeLiveFrame(v);
setLabel(tab, v);
log('page ready to load for tab', id, '→', ROLE === 'follower' ? '(follows the stage)' : url);
return v;
}
function destroy(id) {
var v = views[id];
if (!v) return;
delete views[id];
try { if (v.retry) clearTimeout(v.retry); } catch (_) {}
[v.el, v.frames && v.frames[0], v.frames && v.frames[1], v.status].forEach(function (n) {
try { if (n && n.parentNode) n.parentNode.removeChild(n); } catch (_) {}
});
}
// Keep one view per Browser tab: new tabs (the + menu, Reset) get a page, closed tabs give theirs up.
function reconcile() {
var tabs = browserTabs();
var keep = Object.create(null);
tabs.forEach(function (t, i) {
var id = String(t.id);
keep[id] = true;
if (!views[id]) create(t, i);
});
Object.keys(views).forEach(function (id) { if (!keep[id]) destroy(id); });
// Every tab that was open at load now has its page: nothing left to restore.
if (restore && tabs.length && tabs.every(function (t) { return !!views[String(t.id)]; })) restore = null;
}
/*
* What the tab is about, for the OSC information line ("4/10 Browser | Hope | LIVE") and the phone
* remote: the site's search term when the address carries one, else the page title.
*/
function setLabel(tab, v) {
if (!tab) return;
var label = '';
try {
var u = new URL(v.url);
label = u.searchParams.get('q') || u.searchParams.get('query') || u.searchParams.get('search') || '';
} catch (_) {}
if (!label) label = v.title || '';
label = String(label).replace(/\s+/g, ' ').trim().slice(0, 40);
if (tab.browserLabel === label) return;
tab.browserLabel = label;
tab.mediaTitle = label || 'Browser';
try { if (typeof window.scheduleCompanionActiveTabFeedback === 'function') window.scheduleCompanionActiveTabFeedback(); } catch (_) {}
}
// ---- stage: a real page in a <webview>
var scannerSrc = null;
function loadScanner() {
if (scannerSrc !== null) return Promise.resolve(scannerSrc);
// The app's own scanner files, handed to the page's preload (see browser-guest-preload.cjs for why).
return Promise.all(['book-aliases.js', 'scripture-scan.js'].map(function (f) {
return fetch(f).then(function (r) { return r.ok ? r.text() : ''; }).catch(function () { return ''; });
})).then(function (parts) {
scannerSrc = (parts[0] && parts[1]) ? parts.join('\n;\n') : '';
return scannerSrc;
});
}
function makeWebview(v) {
var wv = document.createElement('webview');
wv.className = 'bt-view';
wv.setAttribute('partition', PARTITION);
// main.cjs forces the real preferences in will-attach-webview; these only state the intent.
wv.setAttribute('webpreferences', 'contextIsolation=yes,sandbox=yes,backgroundThrottling=no');
// Lets main see window.open / target=_blank so it can open them in this same view (setWindowOpenHandler),
// instead of the request vanishing silently or a new window landing on the stage.
wv.setAttribute('allowpopups', '');
wv.setAttribute('src', v.url);
v.kind = 'webview';
v.el = wv;
wv.addEventListener('dom-ready', function () {
v.ready = true;
applyZoom(v);
try { wv.insertCSS(CLEAN_CSS); } catch (_) {}
loadScanner().then(function (src) {
try { wv.send('bibleapp:init', { scanner: src, taps: tapsOn(), wantDom: wantDom }); } catch (_) {}
});
});
wv.addEventListener('did-finish-load', function () {
setStatus(v, '');
wv.classList.add('bt-painted');
if (v.pendingScroll) {
// The page builds its content after load (this site fetches its results), so restoring where it
// was has to wait until there is something to scroll to.
var y = v.pendingScroll; v.pendingScroll = null;
[300, 1200, 3000, 6000].forEach(function (ms) {
setTimeout(function () { if (v.scrollY < y - 4) scrollView(v, { to: y }); }, ms);
});
}
});
wv.addEventListener('did-fail-load', function (e) {
// -3 is "aborted": a navigation superseded by another one. Not a failure anyone needs to see.
if (!e || !e.isMainFrame || e.errorCode === -3) return;
var hostName = '';
try { hostName = new URL(e.validatedURL || v.url).host; } catch (_) {}
setStatus(v, 'Can’t reach ' + (hostName || 'the page') + ' right now. Trying again…');
// The TV often boots before its network is up; keep trying instead of leaving a blank tab.
if (v.retry) clearTimeout(v.retry);
v.retry = setTimeout(function () { v.retry = 0; try { wv.loadURL(v.url); } catch (_) {} }, RETRY_MS);
});
wv.addEventListener('render-process-gone', function () {
setStatus(v, 'The page stopped. Reloading…');
setTimeout(function () { try { wv.reload(); } catch (_) {} }, 1500);
});
var onNav = function (e) {
if (e && e.isMainFrame === false) return;
if (e && e.url && /^https?:/i.test(e.url)) v.url = e.url;
setLabel(tabById(v.id), v);
saveSessionSoon();
};
wv.addEventListener('did-navigate', onNav);
wv.addEventListener('did-navigate-in-page', onNav);
wv.addEventListener('page-title-updated', function (e) { v.title = (e && e.title) || ''; setLabel(tabById(v.id), v); });
wv.addEventListener('ipc-message', function (e) { onGuestMessage(v, e.channel, e.args && e.args[0]); });
host().appendChild(wv);
}
function onGuestMessage(v, channel, d) {
switch (channel) {
case 'bibleapp:scroll':
if (!d) return;
v.scrollY = Number(d.y) || 0;
v.scrollMax = Number(d.max) || 0;
v.vh = Number(d.vh) || 0;
saveSessionSoon();
return;
case 'bibleapp:editable':
// Only the page on screen may raise or drop the keyboard; a background page focusing its own
// search box as it loads must not.
if (!d || !isShown(v)) return;
if (d.on && d.tap) showKeyboard(v, d);
// Focus moved on to a DIFFERENT field while typing (Tab, the next memorize blank): follow it.
else if (d.on && kb && kb.el.style.display === 'block' && d.field && d.field !== kb.field) placeKeyboard(v, d);
else if (!d.on) hideKeyboard();
return;
case 'bibleapp:activity':
// Input inside a webview never reaches the app's own listeners, so the idle splash would otherwise
// come down over the page while pastor is scrolling it.
try { if (typeof window.__resetIdleTimer === 'function') window.__resetIdleTimer(); } catch (_) {}
return;
case 'bibleapp:five-finger':
try {
if (ls('fiveFingerSplash') !== '0' && typeof window.showSplash === 'function') window.showSplash('five-finger touch (browser tab)');
} catch (_) {}
return;
case 'bibleapp:scripture':
if (!d || !d.book || !d.chapter || !isShown(v)) return;
log('opening', d.canonical, d.translation ? '(' + d.translation + ')' : '');
try {
if (typeof window.openPassageInNewTab === 'function') {
window.openPassageInNewTab(d.book, d.chapter, d.verse || 1, d.verseEnd || null, d.translation || null);
}
} catch (_) {}
return;
case 'bibleapp:dom':
if (!d || typeof d.html !== 'string') return;
// Our own counter, never the page's: the page's restarts at 1 with every load, and so would ours
// with every app restart — a booth that had already shown "1" would then ignore the new page.
v.domVer = Math.max(Date.now(), (v.domVer || 0) + 1);
v.domHtml = d.html;
return;
case 'bibleapp:inputs':
if (d && Array.isArray(d.values)) v.inputs = d.values;
return;
case 'bibleapp:ready':
if (d && d.url) v.url = d.url;
if (d && d.title) v.title = d.title;
setLabel(tabById(v.id), v);
return;
}
}
function applyZoom(v) {
var h = host();
var z = zoomFor(h ? h.clientWidth : 0);
if (v.kind === 'webview') {
if (!v.ready) return;
try { v.el.setZoomFactor(z); v.zoom = z; } catch (_) {}
} else if (v.kind === 'live') {
sizeFrame(v.el, z);
v.zoom = z;
}
}
// An iframe has no zoom of its own: lay it out at 1/zoom of the area and scale it back up. Text wraps
// exactly as in the stage's zoomed webview, so the booth's copy scrolls to the same lines.
function sizeFrame(f, z) {
if (!f) return;
z = (isFinite(z) && z > 0) ? z : 1;
f.style.width = (100 / z) + '%';
f.style.height = (100 / z) + '%';
f.style.transform = z === 1 ? '' : 'scale(' + z + ')';
}
function scrollView(v, cmd) {
if (!v) return false;
if (v.kind === 'webview') {
if (!v.ready) return false;
try { v.el.send('bibleapp:scroll', cmd); } catch (_) { return false; }
return true;
}
// live / follower frames: same-origin only (the follower's copy is); a cross-origin live page scrolls by touch.
var f = v.kind === 'snapshot' ? v.frames[v.front] : v.el;
try {
var w = f.contentWindow, d = f.contentDocument;
var se = d.scrollingElement || d.documentElement;
var max = Math.max(0, se.scrollHeight - se.clientHeight);
var y = cmd.edge === 'top' ? 0 : cmd.edge === 'bottom' ? max
: typeof cmd.to === 'number' ? cmd.to
: typeof cmd.by === 'number' ? se.scrollTop + cmd.by
: typeof cmd.pages === 'number' ? se.scrollTop + cmd.pages * w.innerHeight * 0.85 : null;
if (y === null) return false;
w.scrollTo({ top: Math.max(0, Math.min(max, y)), behavior: cmd.smooth ? 'smooth' : 'instant' });
return true;
} catch (_) { return false; }
}
function go(v, url) {
if (!v || !/^https?:\/\//i.test(url || '')) return;
v.url = url;
v.pendingScroll = null;
if (v.kind === 'webview') { try { v.el.loadURL(url); } catch (_) { v.el.setAttribute('src', url); } }
else if (v.kind === 'live') v.el.setAttribute('src', url);
setLabel(tabById(v.id), v);
saveSessionSoon();
}
function reload(v) {
if (!v) return;
if (v.kind === 'webview') { try { v.el.reload(); } catch (_) {} }
else if (v.kind === 'live') { var s = v.el.getAttribute('src'); v.el.setAttribute('src', 'about:blank'); setTimeout(function () { v.el.setAttribute('src', s); }, 30); }
}
// ---- live: a plain browser (admin offstage copy, desktop dev)
function makeLiveFrame(v) {
var f = document.createElement('iframe');
f.className = 'bt-view';
// Scripts and forms work; the page may not navigate the admin's own window away (frame-busting).
f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
f.setAttribute('referrerpolicy', 'no-referrer');
f.setAttribute('src', v.url);
v.kind = 'live';
v.el = f;
host().appendChild(f);
applyZoom(v);
}
// ---- follower: the stage's page, as DOM
/*
* Two frames, swapped once the incoming copy has loaded and been scrolled into place, so a change on the
* stage never shows the program feed a blank frame or a jump to the top in between. allow-same-origin
* WITHOUT allow-scripts: nothing in the copy can run, and this file can still scroll it and fill its fields.
*/
function makeSnapshotFrames(v) {
var mk = function () {
var f = document.createElement('iframe');
f.className = 'bt-view';
f.setAttribute('sandbox', 'allow-same-origin');
f.setAttribute('tabindex', '-1');
f.setAttribute('aria-hidden', 'true');
host().appendChild(f);
return f;
};
v.kind = 'snapshot';
v.frames = [mk(), mk()];
v.front = 0;
v.el = v.frames[0];
v.loadingVer = 0;
}
function followerHtml(html) {
var extra = '<style data-bibleapp>' + CLEAN_CSS + 'html{scroll-behavior:auto!important;}</style>';
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, extra + '</head>') : extra + html;
}
function applyInputsTo(frame, values) {
if (!frame || !Array.isArray(values)) return;
try {
var list = frame.contentDocument.querySelectorAll('input, textarea, select');
for (var i = 0; i < list.length && i < values.length; i++) {
var el = list[i], val = values[i];
if (el.tagName === 'SELECT') { if (typeof val === 'number') el.selectedIndex = val; }
else if (el.type === 'checkbox' || el.type === 'radio') el.checked = !!val;
else if (el.value !== String(val == null ? '' : val)) el.value = String(val == null ? '' : val);
}
} catch (_) {}
}
function applyScrollTo(frame, y) {
try { frame.contentWindow.scrollTo({ top: y, behavior: 'instant' }); return true; } catch (_) { return false; }
}
function showFront(v) {
v.frames.forEach(function (f, i) { f.classList.toggle('bt-active', i === v.front && isShown(v)); });
v.el = v.frames[v.front];
}
function followerApply(v, b) {
if (!v || v.kind !== 'snapshot' || !b) return;
if (typeof b.zoom === 'number' && b.zoom > 0 && b.zoom !== v.zoom) {
v.zoom = b.zoom;
v.frames.forEach(function (f) { sizeFrame(f, b.zoom); });
}
if (typeof b.url === 'string') v.url = b.url;
if (Array.isArray(b.inputs)) v.inputs = b.inputs;
if (typeof b.scrollY === 'number') v.scrollY = b.scrollY;
if (typeof b.html === 'string' && b.domVer && b.domVer !== v.domVer && b.domVer !== v.loadingVer) {
var backIdx = 1 - v.front, back = v.frames[backIdx], ver = b.domVer;
v.loadingVer = ver;
back.onload = function () {
if (v.loadingVer !== ver) return; // a newer copy overtook this one
applyInputsTo(back, v.inputs);
applyScrollTo(back, v.scrollY);
v.front = backIdx;
v.domVer = ver;
v.loadingVer = 0;
showFront(v);
// A late image can still grow the page; settle the scroll once more after it has had a moment.
setTimeout(function () { applyScrollTo(v.frames[v.front], v.scrollY); }, 250);
};
back.srcdoc = followerHtml(b.html);
}
var front = v.frames[v.front];
if (Array.isArray(b.inputs)) applyInputsTo(front, b.inputs);
if (typeof b.scrollY === 'number') applyScrollTo(front, b.scrollY);
}
// ------------------------------------------------------------------ show / hide
var lastActiveId = null;
function sync() {
if (!app()) return;
reconcile();
var h = host();
if (!h) return;
var t = activeTab();
var on = !!(t && t.mode === 'browser');
h.classList.toggle('bt-on', on);
Object.keys(views).forEach(function (id) {
var v = views[id];
var mine = on && String(t.id) === id;
if (v.kind === 'snapshot') showFront(v);
else if (v.el) v.el.classList.toggle('bt-active', mine);
if (v.status) v.status.classList.toggle('bt-show', mine && !!v.status.textContent);
if (mine) applyZoom(v);
});
var id = t ? String(t.id) : null;
if (id !== lastActiveId) {
lastActiveId = id;
// Leaving the tab puts the keyboard away; it comes back with the next tap on a field.
if (!on || !views[id]) hideKeyboard();
}
}
// ------------------------------------------------------------------ the app's keyboard, typing into the page
/*
* The Prayer tab's keyboard, built by the same code (BibleApp.createPrayerKeyboard) so it looks and
* behaves identically — same keys, shift / caps lock, dragging — with a sink that types into the page
* instead of a contenteditable. It lives on <body>, not in the panel, so it survives tab switches.
* Keys never take focus (createPrayerKeyboard handles that when given a sink): the page's field keeps
* its caret, and insertText is a real, trusted text input that frameworks see as typing.
*/
var kb = null;
var sink = {
insertText: function (text) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return;
try { v.el.focus(); v.el.insertText(text); } catch (_) {}
},
key: function (name) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return;
var code = name === 'Enter' ? 'Return' : name;
try {
v.el.focus();
v.el.sendInputEvent({ type: 'keyDown', keyCode: code });
// A real Enter also produces a character event; forms submit on it.
if (name === 'Enter') v.el.sendInputEvent({ type: 'char', keyCode: '\r' });
v.el.sendInputEvent({ type: 'keyUp', keyCode: code });
} catch (_) {}
// Enter is "done": the search runs and the keyboard gets out of the way of the results. A tap on a
// field brings it back.
if (name === 'Enter') hideKeyboard();
},
focus: function () {
var v = activeView();
if (v && v.kind === 'webview') { try { v.el.focus(); } catch (_) {} }
}
};
function keyboard() {
if (kb) return kb;
var a = app();
if (!a || typeof a.createPrayerKeyboard !== 'function') return null;
var el = document.createElement('div');
el.id = 'browserKeyboard';
el.className = 'prayer-keyboard browser-keyboard';
el.style.display = 'none';
document.body.appendChild(el);
a.createPrayerKeyboard(el, null, 'browser', sink);
kb = { el: el, field: null };
return kb;
}
/*
* The keyboard opens directly under the field being typed into. The stage TV is portrait: anything at
* the bottom of the screen is a reach to the floor, and the page's search box is near the top. If the
* field is too low for the keyboard to fit under it, it goes just above instead. Re-tapping the same
* field while the keyboard is up leaves it wherever it was dragged.
*/
function placeKeyboard(v, d) {
var k = kb;
if (!k || !d || !d.rect || !v || !v.el) return;
var W = v.el.getBoundingClientRect(), z = v.zoom || 1;
var fieldTop = W.top + d.rect.y * z;
var fieldBottom = W.top + (d.rect.y + d.rect.h) * z;
var fieldMid = W.left + (d.rect.x + d.rect.w / 2) * z;
var w = k.el.offsetWidth || 760, h = k.el.offsetHeight || 340, gap = 14;
var y = fieldBottom + gap;
if (y + h > window.innerHeight - 8) {
var above = fieldTop - gap - h;
y = above >= 0 ? above : Math.max(0, window.innerHeight - h - 8);
}
var x = Math.max(0, Math.min(window.innerWidth - w, Math.round(fieldMid - w / 2)));
k.el.style.left = x + 'px';
k.el.style.top = Math.round(y) + 'px';
k.field = d.field || null;
}
function showKeyboard(v, d) {
if (ROLE !== 'stage') return;
var k = keyboard();
if (!k) return;
var wasHidden = k.el.style.display !== 'block';
k.el.style.display = 'block'; // shown before measuring: a hidden element has no size
if (d && d.rect && (wasHidden || d.field !== k.field)) placeKeyboard(v, d);
else if (wasHidden && !k.field) {
// No position from the page (should not happen): centre it rather than leave it anywhere odd.
k.el.style.left = Math.max(0, Math.round((window.innerWidth - (k.el.offsetWidth || 760)) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round((window.innerHeight - (k.el.offsetHeight || 340)) / 3)) + 'px';
}
}
function hideKeyboard() { if (kb) kb.el.style.display = 'none'; }
// ------------------------------------------------------------------ booth follower: publishing side
var wantDom = false;
function setWantDom(on) {
if (on === wantDom) return;
wantDom = on;
Object.keys(views).forEach(function (id) {
var v = views[id];
if (v.kind === 'webview' && v.ready) { try { v.el.send('bibleapp:want-dom', on); } catch (_) {} }
});
}
// ------------------------------------------------------------------ public API
function viewFor(tabId) {
var t = tabById(tabId);
return (t && t.mode === 'browser') ? (views[String(t.id)] || null) : null;
}
function firstView() { var t = browserTabs()[0]; return t ? views[String(t.id)] || null : null; }
var gesture = { id: null, base: 0, seq: -1 };
window.__browserTab = {
role: ROLE,
sync: sync,
resetSession: resetSession,
isBrowserTab: function (tabId) { return !!viewFor(tabId); },
/*
* Phone remote + header nav + OSC. Each returns true when it handled the action, so app.js falls
* through to its own Bible behaviour for every other tab.
* edge: 'top' | 'bottom' pages: +1 / -1 (the ±5-verse buttons become a screenful)
*/
scroll: function (tabId, cmd) { var v = viewFor(tabId); return v ? scrollView(v, cmd) : false; },
/*
* The phone's two-finger (on this tab, also one-finger) drag. Same contract as the Bible version in
* app.js: `t` is the gesture's CUMULATIVE travel as a fraction of the window height, restated in every
* packet so a lost one self-heals. The page scrolls in its own (zoomed) pixels, so window travel is
* divided by the zoom: a finger moving 10% of the picture moves the page 10% of the picture.
*/
mirrorScroll: function (tabId, payload) {
var v = viewFor(tabId);
if (!v) return false;
var z = v.zoom || 1;
if (payload.t !== undefined) {
var t = Number(payload.t), seq = Number(payload.seq);
if (!isFinite(t)) return true;
if (gesture.id !== payload.gesture) { gesture.id = payload.gesture; gesture.base = v.scrollY; gesture.seq = -1; }
if (isFinite(seq)) { if (seq <= gesture.seq) return true; gesture.seq = seq; }
scrollView(v, { to: gesture.base - (t * window.innerHeight) / z });
} else {
var dy = Number(payload.dy);
if (isFinite(dy) && dy) scrollView(v, { by: -(dy * window.innerHeight) / z });
}
return true;
},
/*
* Where the page is scrolled, as the phone mirror reads a Bible tab: fractions of the WINDOW height
* (mirror-stream.js), so the phone's local preview moves the picture by the right amount and stops at
* the real bottom of the page.
*/
mirrorScrollState: function (tabId) {
var v = viewFor(tabId);
if (!v || !window.innerHeight) return null;
var z = v.zoom || 1;
return { sc: (v.scrollY * z) / window.innerHeight, sm: (v.scrollMax * z) / window.innerHeight };
},
/*
* A tap on the phone's picture of the TV. elementFromPoint over a page only finds the <webview> itself,
* so the tap is replayed INSIDE the page as real mouse input, where it can press a button, focus the
* search box or open a scripture reference exactly as a finger on the glass would.
*/
tapAt: function (x, y) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return false;
var r = v.el.getBoundingClientRect();
if (x < r.left || x > r.right || y < r.top || y > r.bottom) return false;
var opts = { x: Math.round(x - r.left), y: Math.round(y - r.top), button: 'left', clickCount: 1 };
try {
v.el.focus();
v.el.sendInputEvent(Object.assign({ type: 'mouseDown' }, opts));
v.el.sendInputEvent(Object.assign({ type: 'mouseUp' }, opts));
} catch (_) {}
return true;
},
// Recovery — OSC /bibleapp/browser/reload and /home, the admin console, Settings.
reload: function (tabId) { var v = tabId ? viewFor(tabId) : (activeView() || firstView()); reload(v); return !!v; },
home: function (tabId) {
var list = tabId ? [viewFor(tabId)] : (activeView() ? [activeView()] : Object.keys(views).map(function (id) { return views[id]; }));
list.forEach(function (v) { if (v) go(v, startUrl()); });
return list.length > 0;
},
// localStorage changed under us (phone / admin setSetting, or the Settings page in another window).
applySettings: function (key) {
if (key === KEY_URL) { resetSession(); Object.keys(views).forEach(function (id) { go(views[id], startUrl()); }); }
else if (key === KEY_ZOOM) Object.keys(views).forEach(function (id) { applyZoom(views[id]); });
else if (key === KEY_TAPS) Object.keys(views).forEach(function (id) {
var v = views[id];
if (v.kind === 'webview' && v.ready) { try { v.el.send('bibleapp:taps', tapsOn()); } catch (_) {} }
});
},
// ---- follower-publish.js (stage side): the page as the booth needs it
stageState: function (tabId) {
var v = viewFor(tabId);
if (!v) return null;
return { url: v.url, zoom: v.zoom || 1, domVer: v.domVer || 0, scrollY: v.scrollY || 0, inputs: v.inputs || null };
},
stageHtml: function (tabId) { var v = viewFor(tabId); return (v && v.domHtml) ? { ver: v.domVer, html: v.domHtml } : null; },
// ---- follower-view.js (booth side)
applyFollower: function (tabId, b) { followerApply(viewFor(tabId), b); },
applyFollowerScroll: function (tabId, y) { followerApply(viewFor(tabId), { scrollY: Number(y) || 0 }); },
applyFollowerInputs: function (tabId, values) { followerApply(viewFor(tabId), { inputs: values }); },
// Diagnostics (DevTools): __browserTab.debug()
debug: function () {
return { role: ROLE, wantDom: wantDom, start: startUrl(), views: Object.keys(views).map(function (id) {
var v = views[id];
return { id: id, kind: v.kind, url: v.url, ready: v.ready, zoom: v.zoom, scrollY: v.scrollY, scrollMax: v.scrollMax, domVer: v.domVer, htmlBytes: v.domHtml ? v.domHtml.length : 0 };
}) };
}
};
// ------------------------------------------------------------------ boot
function boot() {
if (!app()) { setTimeout(boot, 120); return; } // app.js loads as a module, after this classic script
sync();
/*
* Floor under the hooks in app.js (renderTabs / renderTabContent call sync): a tab change that reaches
* activeTabId by some other road still shows or hides the page within a moment. Cheap — it only acts
* when the active tab id or the set of Browser tabs actually changed.
*/
var lastSig = '';
setInterval(function () {
var a = app();
if (!a) return;
var sig = String(a.activeTabId) + '|' + browserTabs().map(function (t) { return t.id; }).join(',');
if (sig !== lastSig) { lastSig = sig; sync(); }
// Publish the page's DOM only while a booth screen is linked; nobody else needs it.
if (ROLE === 'stage') {
var fp = window.__followerPublish;
setWantDom(!!(fp && typeof fp.isOpen === 'function' && fp.isOpen()));
}
}, 500);
window.addEventListener('resize', function () { var v = activeView(); if (v) applyZoom(v); });
// The Settings page is the same origin: its writes arrive here as storage events if it is ever open
// beside the app rather than in place of it.
window.addEventListener('storage', function (ev) {
if (ev && (ev.key === KEY_URL || ev.key === KEY_ZOOM || ev.key === KEY_TAPS)) window.__browserTab.applySettings(ev.key);
});
log('ready as', ROLE);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();