/* * 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 — 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 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 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 '; return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, extra + '') : 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 , 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 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(); })();