The stage TV is portrait, and the keyboard opened at the bottom of the screen — a reach to the floor to type into a search box at the top. - browser-guest-preload.cjs reports the tapped field's position (and which field it is) with the tap, and again when focus moves to another field. - browser-tab.js opens the keyboard directly under that field, centred on it and kept on screen; if there is no room under it, just above it. Focus moving to a different field (Tab, the next memorize blank) moves the keyboard with it. Re-tapping the same field leaves a dragged keyboard where it was. - The keyboard's Enter submits to the page and then hides the keyboard, so search results are not covered. Tapping a field brings it back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
822 lines
38 KiB
JavaScript
822 lines
38 KiB
JavaScript
/*
|
||
* 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(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();
|
||
})();
|