Files
bible-app/browser-tab.js
T

793 lines
36 KiB
JavaScript
Raw Normal View History

/*
* 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 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.
*/
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;}' +
'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();
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 (_) {}
},
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, placed: false };
return kb;
}
function showKeyboard() {
if (ROLE !== 'stage') return;
var k = keyboard();
if (!k) return;
k.el.style.display = 'block';
// First time: bottom centre, clear of the page's search box at the top. After that, wherever it was dragged.
if (!k.placed) {
k.placed = true;
var w = k.el.offsetWidth || 760, hgt = k.el.offsetHeight || 340;
k.el.style.left = Math.max(0, Math.round((window.innerWidth - w) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round(window.innerHeight - hgt - Math.max(24, window.innerHeight * 0.04))) + '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();
})();