From e7e527197fb63542c1fb879111cb73a55d65a31f Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 21 Oct 2026 20:25:15 -0400 Subject: [PATCH] Add the Browser tab: a live web page with no browser chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new tab (globe, after Maps) shows one web page full-bleed in the content area — no address bar, bookmarks or scrollbars — built for It Is Written but usable with any page. It loads at startup and keeps running in the background. Stage (browser-tab.js, browser-guest-preload.cjs): - An Electron per Browser tab, in its own sandboxed process and persistent session. The pages live in one persistent layer inside .tab-content: every tab switch rebuilds .tab-content and moving a webview reloads it, so app.js now clears around that layer (clearTabContentKeepingBrowser). - Tapping a text field brings up the Prayer tab's keyboard (createPrayerKeyboard with a sink) and types into the page with trusted input; Enter submits; the keyboard puts itself away when the page moves focus on (e.g. after a search). The OS touch keyboard is suppressed with inputmode="none" while a field is focused. - Tapping a scripture reference opens it in a new Bible tab, in the translation printed beside it when that one is switched on. References are found with the app's own scanner: scripture-scan.js gains scanAll() (every match with its offsets), translationAfter() and isTranslation(); scanText/parseSlide are unchanged. - Input inside a webview never reaches the app, so the page reports activity (idle splash) and five-finger touches (blank the screen) itself. - main.cjs: webviewTag on; every webview is forced sandboxed, context-isolated, no Node, http(s) only, with the guest preload. No new windows (opened in place), no permission prompts, no downloads, no pinch-zoom, no alert/confirm/prompt boxes, and "Leave site?" can never block Reload or Start page. The session reports plain Chrome. - server.js CSP: frame-src allows http(s) (a webview is a frame to CSP); style-src and font-src allow https (the follower's srcdoc copy inherits this policy). Everywhere a tab kind is listed: - app.js: create at startup and on Reset, icon, visibility defaults, + menu, fixed mode, no ink layer, tool handling, OSC kind + information line (the page's search term), phone remote scroll / tap / page buttons, OSC show / reload / home actions, live setting changes, translation for tapped references. - quick-dock, tool-wheel, presentation-tools: no drawing tools on the tab. - Phone remote: tab label, one finger scrolls the page (taps still tap), hint text; mirror-stream.js reports the page's scroll for the phone's local preview. - Follower screen: the stage publishes a script-free copy of the page's DOM (only while a follower is linked), its scroll and form fields; the follower shows it in sandboxed, double-buffered frames; the relay keeps the page for late joiners. - OSC: /bibleapp/browser, /bibleapp/browser/reload, /bibleapp/browser/home; tab_kind/browser feedback. Admin and phone allow-lists updated. - Settings: Tab Visibility, a Browser Tab section (start page, page size, scripture taps, return to start page), Service Profiles carry its settings, OSC references. - Admin console: show the tab, reload / start page, start page and page size. - BROWSER_TAB.md documents it. Co-Authored-By: Claude Opus 5.5 --- BROWSER_TAB.md | 79 ++++ OSC_FEEDBACK_PATHS.md | 2 +- admin.html | 22 +- app.js | 188 +++++++-- browser-guest-preload.cjs | 388 +++++++++++++++++++ browser-tab.js | 792 ++++++++++++++++++++++++++++++++++++++ follower-link.js | 14 + follower-publish.js | 32 +- follower-view.js | 4 + index.html | 3 + main.cjs | 61 +++ mirror-stream.js | 3 + presentation-tools.js | 4 +- quick-dock.js | 4 +- remote-state.js | 3 +- remote.html | 52 ++- scripture-scan.js | 49 ++- server.js | 36 +- settings.html | 102 ++++- tool-wheel.js | 2 +- 20 files changed, 1780 insertions(+), 60 deletions(-) create mode 100644 BROWSER_TAB.md create mode 100644 browser-guest-preload.cjs create mode 100644 browser-tab.js diff --git a/BROWSER_TAB.md b/BROWSER_TAB.md new file mode 100644 index 0000000..0e0b23f --- /dev/null +++ b/BROWSER_TAB.md @@ -0,0 +1,79 @@ +# Browser tab + +One web page, full screen in the tab: no address bar, bookmarks or scrollbars — just the page, in the +same area the Graphics tab uses. It loads when the app starts and keeps running while other tabs are +showing, so it is ready the moment it is opened. Built for It Is Written +(`https://it-is-written-waters-church.vercel.app/`), but any web page works. + +## On the stage + +| Do this | What happens | +|---|---| +| Drag a finger | The page scrolls. | +| Tap a text box on the page | The app's own keyboard (the Prayer tab's) comes up and types into it. Enter submits; the keyboard puts itself away when the page moves on (e.g. after a search). × closes it; tap the box again to bring it back. | +| Tap a scripture reference (e.g. "Isaiah 41:10") | It opens in a new Bible tab, at that verse, in the translation printed beside it (NIV, ESV…) when that translation is switched on. | +| Tap anything else | Works on the page as normal. | +| Leave the tab and come back | The page is exactly as it was: same search, same place, same typing. | + +Pen, highlighter, laser and spotlight are off on this tab (the page takes every touch — that is how it +scrolls and is typed into), as on Graphics and Maps. Touches on the page keep the idle splash away, and +five fingers still blank the screen. + +**If the page gets stuck or a tap wanders off the site:** the ⟳ refresh button puts it back on its start +page (along with the rest of the app), or use Companion / the admin console below. + +## Settings › Browser Tab + +- **Start page** — the page the tab opens. Blank = It Is Written. Changing it takes the tab there. +- **Page size** — Auto lays the page out like a tablet and scales it to fill the width (about 200% on the + portrait TV). Pick a percentage if a site looks too small or too big. +- **Tap a scripture reference…** — on by default. +- **Return to the start page** — the tab remembers where it was while the app runs (so a trip to Settings + does not lose a search); this clears that. + +All three settings travel with **Service Profiles**, so each service type can have its own start page. +The tab can be hidden in **Tab Visibility** like any other, and more can be opened from the **+** menu. + +## From Companion, the phone and the admin console + +| | | +|---|---| +| OSC `/bibleapp/browser` | Show the Browser tab | +| OSC `/bibleapp/browser/reload` | Reload the page | +| OSC `/bibleapp/browser/home` | Back to the start page | +| Feedback `/bibleapp/feedback/state/tab_kind/browser` | `1` while it is showing | +| Information line | `4/10 Browser \| hope \| LIVE` — the page's search term (or title) | + +The **phone remote** shows the page in its TV view: tap to press things on it, drag (one finger or two) +to scroll, and the page buttons move it a screen at a time. The **admin console** can show the tab, +reload it, send it home, and change the start page and page size. + +The **follower screen** shows the same page, clean, and follows its scroll, typing and anything opened on +it. It is fed the stage's copy of the page rather than loading the site itself — loading it would re-run +the search and could put different results on the program feed. + +## How it works + +- **Stage:** each Browser tab is an Electron `` — Chrome's engine in its own sandboxed process + and its own session (`persist:browser-tab`, so a site's settings and sign-ins survive restarts). + `main.cjs` forces its settings: no Node, sandboxed, only http(s), no new windows (links that would open + one open in place), no permission prompts, no downloads, no pinch-zoom, no dialog boxes, and "Leave + site?" can never block Reload or Start page. +- **The pages live in their own layer** (`#browserTabHost`) inside `.tab-content`. Every tab switch + rebuilds `.tab-content`, and moving or recreating a webview reloads its page, so app.js clears around + that layer (`clearTabContentKeepingBrowser`) and switching tabs only shows or hides it. +- **`browser-guest-preload.cjs`** runs inside the page (invisible to the site) and reports what the app + cannot see from outside: a tapped text field, a tapped reference (found with the app's own + `scripture-scan.js` — the same rules ProPresenter Sync uses), scroll, activity, five fingers, and — only + while a follower is linked — a script-free copy of the page's DOM. +- **`browser-tab.js`** owns the tab: the pages, the keyboard (via `createPrayerKeyboard` with a "sink"), + scripture taps, scrolling for the phone / OSC / admin, and the follower's copy. On the follower it + shows that copy in sandboxed frames (no scripts); in a plain browser (admin offstage copy, `npm start`) + it shows the live page in an iframe — keyboard and scripture taps need the stage. + +## Limits worth knowing + +- The follower's copy is the page's DOM, not video: anything drawn by script into a ``, and + script-driven animation, will not move there. Whatever computer shows the follower screen needs to reach the site's fonts + and images over the internet, as the stage does. +- In a plain browser (admin offstage copy) some sites refuse to be shown inside another page. diff --git a/OSC_FEEDBACK_PATHS.md b/OSC_FEEDBACK_PATHS.md index a638e23..5ae3d8b 100644 --- a/OSC_FEEDBACK_PATHS.md +++ b/OSC_FEEDBACK_PATHS.md @@ -86,7 +86,7 @@ also goes out as 1/0 per tab, in two flavours: | `/bibleapp/feedback/state/tab_kind/` | `1` while a tab of that **kind** is showing | `` is one of: `bible` · `sync` · `whiteboard` · `notes` · `media` · `alpha` · `announcements` · -`graphics` · `maps` · `prayer` · `ask`. +`graphics` · `maps` · `browser` · `prayer` · `ask`. - Use **`tab_active`** for a row of buttons that mirrors the tab bar. - Use **`tab_kind`** for a fixed button that asks "is he on the whiteboard?" — that one stays right diff --git a/admin.html b/admin.html index 56a139c..9d166e8 100644 --- a/admin.html +++ b/admin.html @@ -361,7 +361,7 @@ // ---------------------------------------------------------------- catalogue data var TAB_MODES = [ { m: 'bible', t: 'Bible' }, { m: 'announcements', t: 'Announcements' }, - { m: 'graphics', t: 'Graphics' }, { m: 'maps', t: 'Maps & Timelines' }, + { m: 'graphics', t: 'Graphics' }, { m: 'maps', t: 'Maps & Timelines' }, { m: 'browser', t: 'Browser' }, { m: 'whiteboard', t: 'Whiteboard' }, { m: 'notes', t: 'Notes' }, { m: 'prayer', t: 'Prayer' }, { m: 'ask', t: 'Ask (AI)' }, { m: 'ppsync', t: 'ProPresenter Sync' }, { m: 'media', t: 'Media' }, { m: 'alpha', t: 'Alpha' } @@ -502,6 +502,11 @@ btn('◀ Previous tab', 'sec', function () { pushAction('Previous tab', 'switch_tab_left'); }), btn('Next tab ▶', 'sec', function () { pushAction('Next tab', 'switch_tab_right'); }) ])); + // The Browser tab's live web page: put it right without walking to the TV. + s2._body.appendChild(row('Browser tab page', 'Reload it if it hung; Start page if a tap wandered off the site', [ + btn('Reload', 'sec', function () { pushAction('Reload the Browser tab page', 'browserReload'); }), + btn('Start page', 'sec', function () { pushAction('Browser tab back to its start page', 'browserHome'); }) + ])); s2._body.appendChild(row('Open a new tab', 'Added to the end of the tab bar', [ btn('Bible', 'sec', function () { pushAction('New Bible tab', 'new_bible_tab'); }), btn('Whiteboard', 'sec', function () { pushAction('New whiteboard tab', 'new_whiteboard_tab'); }), @@ -602,6 +607,21 @@ pushSetting('Bible text size ' + v + 'px', 'bibleFontSize', String(v)); }) ])); + var bUrl = document.createElement('input'); bUrl.type = 'url'; bUrl.placeholder = 'https://it-is-written-waters-church.vercel.app/'; + bUrl.style.width = '320px'; + b.appendChild(row('Browser tab start page', 'Blank = It Is Written. The tab goes there when queued.', [ + bUrl, btn('Queue', '', function () { + var u = String(bUrl.value || '').trim(); + if (u && !/^https?:\/\//i.test(u)) u = 'https://' + u; + if (u) { try { new URL(u); } catch (_) { return toast('Not a web address', true); } } + pushSetting('Browser tab start page ' + (u || '(It Is Written)'), 'browserTabUrl', u); + }) + ])); + var bZoom = sel([{ v: 'auto', t: 'Auto (fit the screen)' }, { v: '75', t: '75%' }, { v: '100', t: '100%' }, { v: '125', t: '125%' }, + { v: '150', t: '150%' }, { v: '175', t: '175%' }, { v: '200', t: '200%' }, { v: '250', t: '250%' }, { v: '300', t: '300%' }], 'auto'); + b.appendChild(row('Browser tab page size', 'Auto is about 200% on the portrait TV', [ + bZoom, btn('Queue', '', function () { pushSetting('Browser tab page size ' + bZoom.value, 'browserTabZoom', bZoom.value); }) + ])); b.appendChild(row('Word popups', 'Double-tap a word to open its card', [ btn('On', 'sec', function () { pushSetting('Word popups ON', 'wordPopupsEnabled', 'true'); }), btn('Off', 'sec', function () { pushSetting('Word popups OFF', 'wordPopupsEnabled', 'false'); }) diff --git a/app.js b/app.js index c34f49c..ff800d7 100644 --- a/app.js +++ b/app.js @@ -474,6 +474,10 @@ function getPassageKey(book, chapter, verse, verseEnd, translation, mode = 'bibl const tabId = getActiveTabId(); return tabId ? `maps-annotations-${tabId}` : 'maps-annotations'; } + if (mode === 'browser') { + const tabId = getActiveTabId(); + return tabId ? `browser-annotations-${tabId}` : 'browser-annotations'; + } if (mode === 'notes') { const tabId = getActiveTabId(); return `notes-annotations-${tabId}`; @@ -2046,8 +2050,22 @@ function linkifyScriptureRefs(escapedHtml) { } catch (_) { return escapedHtml; } } +/* + * The Browser tab's pages live in one persistent layer inside .tab-content (#browserTabHost, owned by + * browser-tab.js). Moving or re-creating a reloads its page — the search and the scroll would be + * lost on every tab switch — so clearing the panel area removes everything EXCEPT that layer. + */ +function clearTabContentKeepingBrowser(tabContent) { + if (!tabContent) return; + Array.from(tabContent.childNodes).forEach((n) => { + if (n.id !== 'browserTabHost') tabContent.removeChild(n); + }); +} + // Open a passage in a brand-new Bible tab (used by tapped references; mirrors the remote's go-to). -function openPassageInNewTab(book, chapter, verse, verseEnd) { +// `translation` (optional) is honoured only when it is one the operator has switched on — a reference +// tapped on a web page's NIV card opens in NIV, so the room reads the same words it just saw. +function openPassageInNewTab(book, chapter, verse, verseEnd, translation) { try { const app = window.bibleApp; if (!app || !book || !chapter) return; @@ -2058,6 +2076,12 @@ function openPassageInNewTab(book, chapter, verse, verseEnd) { tab.chapter = parseInt(chapter, 10) || 1; tab.verse = parseInt(verse, 10) || 1; tab.verseEnd = verseEnd ? (parseInt(verseEnd, 10) || null) : null; + if (translation) { + try { + const code = String(translation).toUpperCase(); + if (getVisibleTranslationCodes().includes(code)) tab.translation = code; + } catch (_) {} + } tab.loaded = false; // Scroll to the tapped verse once the chapter renders (the chapter opens AT the verse, // not at the top). @@ -2833,14 +2857,17 @@ export default class BibleApp { } const tabContent = document.querySelector('.tab-content'); - if (tabContent) tabContent.innerHTML = ''; + if (tabContent) clearTabContentKeepingBrowser(tabContent); this.tabs = []; this.activeTabId = null; - // Recreate default tabs (announcements, graphics, whiteboard, notes, prayer, ask, bible) + // Reset puts the Browser tab back on its start page as well (browser-tab.js keeps it per session). + try { if (window.__browserTab) window.__browserTab.resetSession(); } catch (_) {} + // Recreate default tabs (announcements, graphics, maps, browser, whiteboard, notes, prayer, ask, bible) runAutomaticOperation(() => { this.createAnnouncementsTab(); this.createGraphicsTab(); this.createMapsTab(); + this.createBrowserTab(); this.createWhiteboardTab(); this.createNotesTab(); this.createPrayerTab(); @@ -3514,6 +3541,7 @@ export default class BibleApp { // Create initial tabs automatically without recording them as user operations: // 1) Announcements tab (leftmost; image-first view) // 2) Graphics tab (image carousel from Graphics/ directory) + // 2b) Maps, then Browser (a live web page — loads now so it is ready when first opened) // 3) Whiteboard tab (not active) // 4) Notes tab // 5) Prayer tab @@ -3523,6 +3551,7 @@ export default class BibleApp { this.createAnnouncementsTab(); this.createGraphicsTab(); this.createMapsTab(); + this.createBrowserTab(); this.createWhiteboardTab(); this.createNotesTab(); this.createPrayerTab(); @@ -5266,6 +5295,29 @@ export default class BibleApp { this._insertGeneralTab(tab); this.renderTabs(); } + // Browser tab: a live web page, full-bleed in the content area with no browser chrome. The page itself + // is owned by browser-tab.js (it loads at startup and keeps running while other tabs are showing). + createBrowserTab() { + // Sort directly after Maps (-14) and before Whiteboard (-10) + const tabId = this.generateUniqueTabId(Date.now() - 13); + const tab = { + id: tabId, + book: '', + chapter: null, + verse: null, + verseEnd: null, + translation: 'ESV', + loaded: true, + mode: 'browser', + mediaContent: null, + mediaTitle: 'Browser', + browserLabel: '', // the page's search term or title (browser-tab.js) — OSC info line, phone remote + isSelectingVerses: false, + verseSelectionCooldown: 0 + }; + this._insertGeneralTab(tab); + this.renderTabs(); + } createWhiteboardTab() { if (!isAutomaticOperation) { pulseCompanionActionFeedback('new_whiteboard_tab'); @@ -5545,8 +5597,12 @@ export default class BibleApp { // }); } - // Create draggable popup keyboard - createPrayerKeyboard(keyboardEl, textEl, tabId) { + // Create draggable popup keyboard. + // `sink` (optional) types somewhere other than a contenteditable — the Browser tab passes one that types + // into its web page: { insertText(text), key('Backspace'|'Enter'|'Tab'), focus() }. With a sink there is + // no formatting toolbar, and keys never take focus, so the page's field keeps its caret between taps. + createPrayerKeyboard(keyboardEl, textEl, tabId, sink = null) { + const kbSlot = sink ? '_browserKbHandlers' : '_prayerKbHandlers'; // separate drag listeners per keyboard // Traditional QWERTY layout: Tab leads the Q-row, Shift leads the Z-row, // Backspace tails the Z-row, and Space + Enter share the action row — // matching a real keyboard so muscle memory works. @@ -5592,6 +5648,7 @@ export default class BibleApp { // Build keyboard HTML with formatting toolbar let keyboardHTML = '
'; keyboardHTML += 'Keyboard'; + if (!sink) { keyboardHTML += '
'; keyboardHTML += ''; keyboardHTML += ''; @@ -5607,6 +5664,7 @@ export default class BibleApp { // Paste icon (clipboard) keyboardHTML += ''; keyboardHTML += '
'; + } keyboardHTML += ``; keyboardHTML += '
'; keyboardHTML += '
'; @@ -5701,12 +5759,12 @@ export default class BibleApp { // Remove any previously-registered prayer-keyboard document listeners so they don't // stack on every re-render (the keyboard DOM is rebuilt each time the prayer tab renders). - if (this._prayerKbHandlers) { - document.removeEventListener('mousemove', this._prayerKbHandlers.mousemove); - document.removeEventListener('touchmove', this._prayerKbHandlers.touchmove); - document.removeEventListener('mouseup', this._prayerKbHandlers.mouseup); - document.removeEventListener('touchend', this._prayerKbHandlers.touchend); - if (this._prayerKbHandlers.selectionchange) document.removeEventListener('selectionchange', this._prayerKbHandlers.selectionchange); + if (this[kbSlot]) { + document.removeEventListener('mousemove', this[kbSlot].mousemove); + document.removeEventListener('touchmove', this[kbSlot].touchmove); + document.removeEventListener('mouseup', this[kbSlot].mouseup); + document.removeEventListener('touchend', this[kbSlot].touchend); + if (this[kbSlot].selectionchange) document.removeEventListener('selectionchange', this[kbSlot].selectionchange); } const onPrayerMouseMove = (e) => { if (isDragging) { @@ -5727,7 +5785,7 @@ export default class BibleApp { document.addEventListener('touchmove', onPrayerTouchMove); document.addEventListener('mouseup', onPrayerMouseUp); document.addEventListener('touchend', onPrayerTouchEnd); - this._prayerKbHandlers = { mousemove: onPrayerMouseMove, touchmove: onPrayerTouchMove, mouseup: onPrayerMouseUp, touchend: onPrayerTouchEnd }; + this[kbSlot] = { mousemove: onPrayerMouseMove, touchmove: onPrayerTouchMove, mouseup: onPrayerMouseUp, touchend: onPrayerTouchEnd }; // Three-state shift: 'off' | 'on' | 'locked' (caps lock). // 'off' -> letters lowercase, numbers as-is @@ -5812,11 +5870,11 @@ export default class BibleApp { btn.addEventListener('click', () => { const key = btn.dataset.key; if (key === 'backspace') { - document.execCommand('delete', false); + if (sink) sink.key('Backspace'); else document.execCommand('delete', false); } else if (key === 'enter') { - insertTextInto('\n'); + if (sink) sink.key('Enter'); else insertTextInto('\n'); } else if (key === 'tab') { - insertTextInto('\t'); + if (sink) sink.key('Tab'); else insertTextInto('\t'); } else if (key === 'shift') { const now = Date.now(); if (shiftState === 'locked') { @@ -5839,15 +5897,27 @@ export default class BibleApp { } else if (/[0-9]/.test(key) && numberShiftActive() && btn.dataset.shift) { textToInsert = btn.dataset.shift; } - insertTextInto(textToInsert); + if (sink) sink.insertText(textToInsert); else insertTextInto(textToInsert); // Auto-release momentary Shift after any character key — but never auto-release Caps Lock. if (shiftState === 'on') { setShiftState('off'); } } - textEl.focus(); + if (sink) sink.focus(); else textEl.focus(); }); + // With a sink, a key must never take focus: the page's field would lose its caret and the next + // key would type into nothing. + if (sink) { + btn.addEventListener('pointerdown', (e) => { e.preventDefault(); }); + btn.addEventListener('mousedown', (e) => { e.preventDefault(); }); + } }); + if (sink) { + // Taps on the keyboard's own background must not take focus either. The header's drag handler + // already prevents its own mousedown, so dragging is unaffected. + keyboardEl.addEventListener('mousedown', (e) => { e.preventDefault(); }); + return; // the rest is the Prayer tab's formatting toolbar + } // Handle formatting button clicks keyboardEl.querySelectorAll('.prayer-format-btn').forEach(btn => { @@ -5958,7 +6028,7 @@ export default class BibleApp { } }; document.addEventListener('selectionchange', onPrayerSelectionChange); - if (this._prayerKbHandlers) this._prayerKbHandlers.selectionchange = onPrayerSelectionChange; + if (this[kbSlot]) this[kbSlot].selectionchange = onPrayerSelectionChange; } // Keyboard shortcuts for formatting and copy/paste @@ -5993,6 +6063,7 @@ export default class BibleApp { if (parsed.ask === undefined) parsed.ask = true; if (parsed.graphics === undefined) parsed.graphics = true; if (parsed.maps === undefined) parsed.maps = true; + if (parsed.browser === undefined) parsed.browser = true; if (parsed.ppsync === undefined) parsed.ppsync = true; } return parsed; @@ -6006,6 +6077,7 @@ export default class BibleApp { announcements: true, graphics: true, maps: true, + browser: true, whiteboard: true, notes: true, prayer: true, @@ -6173,6 +6245,15 @@ export default class BibleApp { M `; + } else if (tab.mode === 'browser') { + // A globe in the same rounded frame as the other display tabs. + tabElement.title = 'Browser' + (tab.browserLabel ? ' \u2014 ' + tab.browserLabel : ''); + iconHTML = ``; } else if (tab.mode === 'prayer') { iconHTML = `
`; + setTimeout(() => { try { if (window.__browserTab) window.__browserTab.sync(); } catch (_) {} }, 0); } else if (tab.mode === 'graphics' || tab.mode === 'maps') { // Graphics & Maps: identical image-first swipeable carousel; only the data source differs // (/api/graphics/list + /Graphics/ vs /api/maps/list + /Maps/). Mirrors the Announcements pattern. @@ -14897,7 +14987,7 @@ function sendCompanionPenColorStateFeedback(color, force = false) { */ const COMPANION_TAB_KIND = { bible: 'Bible', whiteboard: 'Whiteboard', notes: 'Notes', media: 'Media', alpha: 'Alpha', - announcements: 'Announcements', graphics: 'Graphics', maps: 'Maps', prayer: 'Prayer', ask: 'Ask' + announcements: 'Announcements', graphics: 'Graphics', maps: 'Maps', browser: 'Browser', prayer: 'Prayer', ask: 'Ask' }; let companionActiveTabSignature = null; let companionActiveTabTimer = 0; @@ -14917,8 +15007,9 @@ function sendCompanionActiveTabFeedback() { // The same thing again as a stable lowercase slug. kind_ is a label for the information line and // could be reworded; `slug` is what an OSC path is built from, so it must never drift. slug: tab.ppsync ? 'sync' : String(tab.mode || 'tab').toLowerCase(), - passage: (tab.book && tab.chapter) ? `${tab.book} ${tab.chapter}` : null, - translation: tab.translation || null + // The Browser tab has no passage; its search term (or page title) answers "what is up there" instead. + passage: tab.mode === 'browser' ? (tab.browserLabel || null) : ((tab.book && tab.chapter) ? `${tab.book} ${tab.chapter}` : null), + translation: tab.mode === 'browser' ? null : (tab.translation || null) }; const sig = JSON.stringify(payload); if (sig === companionActiveTabSignature) return; @@ -16950,6 +17041,7 @@ document.addEventListener('DOMContentLoaded', () => { { mode: 'announcements', label: 'Announcements', icon: 'A' }, { mode: 'graphics', label: 'Graphics', icon: 'G' }, { mode: 'maps', label: 'Maps', icon: 'M' }, + { mode: 'browser', label: 'Browser', icon: '\u{1F310}' }, { mode: 'whiteboard', label: 'Whiteboard', icon: 'WB' }, { mode: 'notes', label: 'Notes', icon: 'N' }, { mode: 'prayer', label: 'Prayer', icon: 'P' }, @@ -16973,6 +17065,7 @@ document.addEventListener('DOMContentLoaded', () => { announcements: true, graphics: true, maps: true, + browser: true, whiteboard: true, notes: true, prayer: true, @@ -17009,6 +17102,7 @@ document.addEventListener('DOMContentLoaded', () => { if (mode === 'announcements' && typeof app.createAnnouncementsTab === 'function') app.createAnnouncementsTab(); else if (mode === 'graphics' && typeof app.createGraphicsTab === 'function') app.createGraphicsTab(); else if (mode === 'maps' && typeof app.createMapsTab === 'function') app.createMapsTab(); + else if (mode === 'browser' && typeof app.createBrowserTab === 'function') app.createBrowserTab(); else if (mode === 'whiteboard' && typeof app.createWhiteboardTab === 'function') app.createWhiteboardTab(); else if (mode === 'notes' && typeof app.createNotesTab === 'function') app.createNotesTab(); else if (mode === 'prayer' && typeof app.createPrayerTab === 'function') app.createPrayerTab(); @@ -17364,6 +17458,8 @@ try { */ try { const tabId = mirrorTabId(); + // The Browser tab scrolls its web page, with the same gesture contract (browser-tab.js). + if (tabId && window.__browserTab && window.__browserTab.isBrowserTab(tabId)) { window.__browserTab.mirrorScroll(tabId, payload); break; } const readingArea = tabId ? document.getElementById(`readingArea${tabId}`) : null; if (!readingArea) break; if (payload.t !== undefined) { @@ -17439,18 +17535,23 @@ try { // The phone remote's reading-navigation buttons. These call exactly what the on-screen // chapter-header buttons call, so scrolling from the phone behaves identically. case 'scroll_top': { + // Browser tab: its page (the ±5-verse buttons move it a screenful). Every other tab: the header nav. + try { if (window.__browserTab && window.__browserTab.scroll(window.bibleApp.activeTabId, { edge: 'top', smooth: true })) break; } catch (_) {} try { if (window.__headerNav) window.__headerNav.top(window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_bottom': { + try { if (window.__browserTab && window.__browserTab.scroll(window.bibleApp.activeTabId, { edge: 'bottom', smooth: true })) break; } catch (_) {} try { if (window.__headerNav) window.__headerNav.bottom(window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_up_5': { + try { if (window.__browserTab && window.__browserTab.scroll(window.bibleApp.activeTabId, { pages: -1, smooth: true })) break; } catch (_) {} try { if (window.__headerNav) window.__headerNav.by(-5, window.bibleApp.activeTabId); } catch (_) {} break; } case 'scroll_down_5': { + try { if (window.__browserTab && window.__browserTab.scroll(window.bibleApp.activeTabId, { pages: 1, smooth: true })) break; } catch (_) {} try { if (window.__headerNav) window.__headerNav.by(5, window.bibleApp.activeTabId); } catch (_) {} break; } @@ -17658,6 +17759,11 @@ try { localStorage.setItem(key, str); switch (key) { + case 'browserTabUrl': + case 'browserTabZoom': + case 'browserTabScriptureTaps': + try { if (window.__browserTab) window.__browserTab.applySettings(key); } catch (_) {} + break; case 'tabVisibility': // Re-render the tab bar; renderTabs() also moves off a tab it just hid. if (window.bibleApp && typeof window.bibleApp.renderTabs === 'function') window.bibleApp.renderTabs(); @@ -17780,6 +17886,24 @@ try { } catch (_) {} break; } + // OSC /bibleapp/browser — same as /bibleapp/maps: bring the (first) Browser tab up. + case 'showBrowser': { + try { + const app = window.bibleApp; + const t = app && app.tabs && app.tabs.find(x => x.mode === 'browser'); + if (t && typeof app.switchTab === 'function') app.switchTab(t.id); + } catch (_) {} + break; + } + // Recovery for a live web page in front of the room: reload it, or take it back to its start page. + case 'browserReload': { + try { if (window.__browserTab) window.__browserTab.reload(); } catch (_) {} + break; + } + case 'browserHome': { + try { if (window.__browserTab) window.__browserTab.home(); } catch (_) {} + break; + } case 'switchToMode': { try { const app = window.bibleApp; @@ -17841,6 +17965,9 @@ try { const x = px * window.innerWidth, y = py * window.innerHeight; const ov = document.querySelector('.splash-overlay'); if (ov && !ov.classList.contains('hidden')) { try { window.hideSplash(); } catch (_) {} break; } + // Over the Browser tab's page, elementFromPoint finds only the ; the tap is replayed + // inside the page instead (a button, the search box, a scripture reference). + if (window.__browserTab && window.__browserTab.tapAt(x, y)) break; const el = document.elementFromPoint(x, y); if (!el) break; const opts = { clientX: x, clientY: y, bubbles: true, cancelable: true, view: window }; @@ -22762,7 +22889,7 @@ function getOrCreateAnnotationCanvas(tabId) { // Check if this is a media, announcements, prayer, or ask tab - if so, don't create annotation canvases const tab = window.bibleApp?.tabs?.find(t => t.id === tabId); - if (tab && (tab.mode === 'media' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask')) { + if (tab && (tab.mode === 'media' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'browser' || tab.mode === 'prayer' || tab.mode === 'ask')) { console.log('[DEBUG] Skipping annotation canvas creation for', tab.mode, 'tab:', tabId); return null; } @@ -23516,7 +23643,7 @@ function setupAnnotationForActiveTab() { console.log('[DEBUG] *** setupAnnotationForActiveTab called, tabId:', tabId, 'drawing state:', drawing); if (!tabId) return; const tab = getTabById(tabId); - if (tab && (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask')) { + if (tab && (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'browser' || tab.mode === 'prayer' || tab.mode === 'ask')) { console.log('[DEBUG] Skipping annotation setup for', tab.mode, 'tab'); return; } @@ -23994,7 +24121,7 @@ function startDrawing(e) { const tabId = getActiveTabId(); const activeTab = getTabById(tabId); - if (activeTab && (activeTab.mode === 'announcements' || activeTab.mode === 'graphics' || activeTab.mode === 'maps')) { + if (activeTab && (activeTab.mode === 'announcements' || activeTab.mode === 'graphics' || activeTab.mode === 'maps' || activeTab.mode === 'browser')) { console.log('[DEBUG] Drawing blocked on', activeTab.mode, 'tab'); return; } @@ -24711,6 +24838,7 @@ function getPassageKeyForTabId(tabId) { if (tab.mode === 'announcements') return `announcements-annotations-${tabId}`; if (tab.mode === 'graphics') return `graphics-annotations-${tabId}`; if (tab.mode === 'maps') return `maps-annotations-${tabId}`; + if (tab.mode === 'browser') return `browser-annotations-${tabId}`; if (tab.mode === 'notes') return `notes-annotations-${tabId}`; return getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync); } diff --git a/browser-guest-preload.cjs b/browser-guest-preload.cjs new file mode 100644 index 0000000..528bf82 --- /dev/null +++ b/browser-guest-preload.cjs @@ -0,0 +1,388 @@ +/* + * browser-guest-preload.cjs — runs inside the web page that the Browser tab shows (the Electron + * guest). main.cjs forces it onto every webview from will-attach-webview, sandboxed and + * context-isolated: the page can neither see nor call anything here, and this file can only talk to the + * Bible app through ipcRenderer.sendToHost (to the element owned by browser-tab.js) and + * ipcRenderer.on (from it). + * + * Why each piece exists — every one of these is something the app cannot see from outside the page, + * because input inside a webview never reaches the app's own document: + * + * keyboard a tapped text field tells the app to bring up its own on-screen keyboard; + * scripture a tap on "Isaiah 41:10" is found with the app's OWN scanner and handed over to open in a + * new Bible tab; + * scroll reported so the phone remote and the booth follower can track the page, and carried out + * on the app's behalf (phone remote, header nav, OSC); + * follower a script-free copy of the DOM, published only while a booth screen is linked; + * activity touches inside the page count as activity, or the app's idle splash would drop over the + * page while pastor is using it — and the five-finger "blank the screen" gesture still works. + */ +'use strict'; +const { ipcRenderer, webFrame } = require('electron'); + +/* + * No dialog boxes on the stage. alert / confirm / prompt from a page would open a native window over the + * TV. They are replaced in the PAGE's world (the only one its scripts can see) with silent stand-ins: + * confirm answers "Cancel", so nothing a page asks permission for can happen by itself. + */ +try { + webFrame.executeJavaScript('(function(){try{window.alert=function(){};window.confirm=function(){return false;};window.prompt=function(){return null;};}catch(e){}})();'); +} catch (_) {} + +const OVERLAY_ATTR = 'data-bibleapp-overlay'; // our own tap flash; never published, never observed +const send = (ch, data) => { try { ipcRenderer.sendToHost(ch, data); } catch (_) {} }; + +// ------------------------------------------------------------------ settings pushed by the app +/* + * The scanner arrives as SOURCE over IPC instead of being copied into this file: a sandboxed preload + * cannot require the app's files, and a second copy would drift from the one ProPresenter Sync uses. + * It is evaluated here, in this isolated world, so the page never sees it. + */ +let Scan = null; +let tapsOn = true; +let wantDom = false; + +ipcRenderer.on('bibleapp:init', (_e, cfg) => { + cfg = cfg || {}; + if (typeof cfg.taps === 'boolean') tapsOn = cfg.taps; + if (cfg.scanner && !Scan) { + try { + const root = {}; + new Function('globalThis', cfg.scanner)(root); + if (root.ScriptureScan && root.ScriptureScan.scanAll && root.BibleBooks) Scan = root; + } catch (_) { Scan = null; } + } + if (typeof cfg.wantDom === 'boolean') setWantDom(cfg.wantDom); + reportScroll(true); +}); +ipcRenderer.on('bibleapp:taps', (_e, on) => { tapsOn = !!on; }); +ipcRenderer.on('bibleapp:want-dom', (_e, on) => setWantDom(!!on)); +ipcRenderer.on('bibleapp:dom-now', () => { if (wantDom) publishDom(true); }); +ipcRenderer.on('bibleapp:scroll', (_e, cmd) => doScroll(cmd || {})); + +// ------------------------------------------------------------------ scroll +function scroller() { return document.scrollingElement || document.documentElement; } +function maxScroll() { const s = scroller(); return s ? Math.max(0, s.scrollHeight - s.clientHeight) : 0; } + +function doScroll(cmd) { + const s = scroller(); + if (!s) return; + let y = null; + if (cmd.edge === 'top') y = 0; + else if (cmd.edge === 'bottom') y = maxScroll(); + else if (typeof cmd.to === 'number') y = cmd.to; + else if (typeof cmd.by === 'number') y = s.scrollTop + cmd.by; + else if (typeof cmd.pages === 'number') y = s.scrollTop + cmd.pages * window.innerHeight * 0.85; + if (y === null || !isFinite(y)) return; + // 'instant' for a finger dragging on the phone (it restates the position every few ms); smooth for a + // button press. Explicit either way, so a page's own `scroll-behavior` cannot turn a drag into mush. + window.scrollTo({ top: Math.max(0, Math.min(maxScroll(), y)), behavior: cmd.smooth ? 'smooth' : 'instant' }); +} + +let lastY = -1, lastMax = -1, scrollTimer = 0; +function reportScroll(force) { + const s = scroller(); + if (!s) return; + const y = Math.round(s.scrollTop), max = Math.round(maxScroll()); + if (!force && y === lastY && max === lastMax) return; + lastY = y; lastMax = max; + send('bibleapp:scroll', { y: y, max: max, vh: window.innerHeight }); +} +// A timer, not rAF: rAF stops whenever the guest is not painting, and the booth would stop following. +function reportScrollSoon() { if (!scrollTimer) scrollTimer = setTimeout(() => { scrollTimer = 0; reportScroll(false); }, 16); } +window.addEventListener('scroll', reportScrollSoon, { capture: true, passive: true }); +window.addEventListener('resize', reportScrollSoon); +window.addEventListener('load', () => reportScroll(true)); + +// ------------------------------------------------------------------ text fields -> the app's keyboard +function isEditable(el) { + if (!el || el.nodeType !== 1) return false; + if (el.isContentEditable) return true; + if (el.disabled || el.readOnly) return false; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName !== 'INPUT') return false; + return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].indexOf(String(el.type || 'text').toLowerCase()) !== -1; +} +function editableFrom(target) { + if (isEditable(target)) return target; + const el = target && target.closest ? target.closest('input, textarea, [contenteditable]') : null; + return isEditable(el) ? el : null; +} + +/* + * The app's keyboard is the only one that should appear. A finger on a field would otherwise also raise + * the Windows touch keyboard over the stage; inputmode="none" is the browser's own switch for that. It is + * set just before the field takes focus and put back as it leaves, so the page is never left changed. + */ +const savedInputMode = new WeakMap(); +function quietOsKeyboard(el) { + if (!el || savedInputMode.has(el)) return; + savedInputMode.set(el, el.getAttribute('inputmode')); + el.setAttribute('inputmode', 'none'); +} +function restoreOsKeyboard(el) { + if (!el || !savedInputMode.has(el)) return; + const v = savedInputMode.get(el); + savedInputMode.delete(el); + if (v === null) el.removeAttribute('inputmode'); else el.setAttribute('inputmode', v); +} + +/* + * Only a TAP raises the keyboard. A page that focuses its own search box as it loads must not throw a + * keyboard over the stage while nobody is touching it; focus moving between fields (Tab, the next + * memorize blank) keeps an open keyboard open; focus moving to anything that is not a field — the site + * focuses its results heading after a search — puts it away, so the results are not covered. + */ +window.addEventListener('pointerdown', (e) => { + const el = editableFrom(e.target); + if (!el) return; + quietOsKeyboard(el); + send('bibleapp:editable', { on: true, tap: true }); +}, true); +document.addEventListener('focusin', (e) => { + send('bibleapp:editable', { on: isEditable(e.target), tap: false }); +}, true); +document.addEventListener('focusout', (e) => { + restoreOsKeyboard(e.target); + setTimeout(() => { + // The app itself took focus (pastor tapped the tab bar): that is the app's business, not the page's. + if (!document.hasFocus()) return; + if (!isEditable(document.activeElement)) send('bibleapp:editable', { on: false, tap: false }); + }, 0); +}, true); + +// ------------------------------------------------------------------ activity + five fingers +let lastActivity = 0; +function activity() { + const now = Date.now(); + if (now - lastActivity < 1000) return; + lastActivity = now; + send('bibleapp:activity'); +} +['pointerdown', 'wheel', 'keydown', 'touchstart', 'scroll'].forEach((ev) => { + window.addEventListener(ev, activity, { capture: true, passive: true }); +}); +const touchIds = new Set(); +window.addEventListener('pointerdown', (e) => { + if (e.pointerType !== 'touch') return; + touchIds.add(e.pointerId); + if (touchIds.size >= 5) send('bibleapp:five-finger'); +}, true); +['pointerup', 'pointercancel'].forEach((ev) => window.addEventListener(ev, (e) => { if (e.pointerType === 'touch') touchIds.delete(e.pointerId); }, true)); +window.addEventListener('blur', () => touchIds.clear()); + +// ------------------------------------------------------------------ scripture under a tap +const BLOCK = /^(block|flex|inline-flex|grid|inline-grid|list-item|table|table-cell|table-caption|flow-root)$/; +// The nearest block around the tapped text: a reference split across spans ("John 3:16") is still +// one run of text there, where the single text node would only hold half of it. +function blockAround(node) { + let el = node.nodeType === 1 ? node : node.parentElement; + for (let i = 0; el && i < 6; i++, el = el.parentElement) { + if (BLOCK.test(getComputedStyle(el).display)) return el; + } + return node.parentElement || document.body; +} +function textOf(block) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, { + acceptNode(n) { + const p = n.parentElement; + if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/.test(p.tagName) || p.closest('[' + OVERLAY_ATTR + ']')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + const nodes = []; + let text = ''; + for (let n = walker.nextNode(); n && text.length < 6000; n = walker.nextNode()) { + nodes.push({ node: n, start: text.length }); + text += n.nodeValue; + } + return { text, nodes }; +} +function rangeOf(map, start, end) { + const at = (pos) => { + for (let i = map.nodes.length - 1; i >= 0; i--) { + const e = map.nodes[i]; + if (pos >= e.start) return { node: e.node, off: Math.min(pos - e.start, e.node.nodeValue.length) }; + } + return null; + }; + const a = at(start), b = at(end); + if (!a || !b) return null; + const r = document.createRange(); + r.setStart(a.node, a.off); + r.setEnd(b.node, b.off); + return r; +} +// "Isaiah 41:10 (NIV)" or a badge beside it, as the site's verse cards have. +function translationNear(text, end, block) { + const after = Scan.ScriptureScan.translationAfter(text, end); + if (after) return after; + let el = block; + for (let i = 0; el && i < 2; i++, el = el.parentElement) { + const cands = el.querySelectorAll('span, small, sup, abbr, b, strong, em, i, div'); + for (let k = 0; k < cands.length && k < 60; k++) { + const c = cands[k]; + if (c.children.length) continue; + const t = (c.textContent || '').trim(); + if (t && t.length <= 8 && Scan.ScriptureScan.isTranslation(t)) return t.toUpperCase(); + } + } + return null; +} +function referenceAt(x, y) { + const caret = document.caretRangeFromPoint(x, y); + if (!caret || caret.startContainer.nodeType !== 3) return null; + const block = blockAround(caret.startContainer); + const map = textOf(block); + const entry = map.nodes.find((e) => e.node === caret.startContainer); + if (!entry) return null; + const off = entry.start + caret.startOffset; + const hits = Scan.ScriptureScan.scanAll(map.text); + for (const h of hits) { + if (off < h.start || off > h.end) continue; + const range = rangeOf(map, h.start, h.end); + if (!range) continue; + // caretRangeFromPoint snaps to the NEAREST character, even from empty space beside a line. Only a + // finger actually on the reference counts. + const pad = 8; + const onIt = Array.from(range.getClientRects()).some((r) => x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad); + if (onIt) return { ref: h.ref, range: range, translation: translationNear(map.text, h.end, block) }; + } + return null; +} +function flash(range) { + try { + const layer = document.createElement('div'); + layer.setAttribute(OVERLAY_ATTR, ''); + layer.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;'; + Array.from(range.getClientRects()).forEach((r) => { + const d = document.createElement('div'); + d.style.cssText = 'position:fixed;pointer-events:none;border-radius:6px;background:rgba(255,214,10,.32);' + + 'box-shadow:0 0 0 2px rgba(255,214,10,.85);transition:opacity .45s ease .3s;' + + 'left:' + (r.left - 4) + 'px;top:' + (r.top - 3) + 'px;width:' + (r.width + 8) + 'px;height:' + (r.height + 6) + 'px;'; + layer.appendChild(d); + }); + (document.body || document.documentElement).appendChild(layer); + requestAnimationFrame(() => requestAnimationFrame(() => { layer.querySelectorAll('div').forEach((d) => { d.style.opacity = '0'; }); })); + setTimeout(() => { try { layer.remove(); } catch (_) {} }, 1000); + } catch (_) {} +} +// Capture on window, so it runs before the page's own handlers — a reference inside a link opens in the +// app instead of navigating the page away. A tap that is not on a reference is left completely alone. +window.addEventListener('click', (e) => { + if (!tapsOn || !Scan || e.button !== 0) return; + if (editableFrom(e.target) || (e.target.closest && e.target.closest('select, button[type="submit"]'))) return; + let found = null; + try { found = referenceAt(e.clientX, e.clientY); } catch (_) { found = null; } + if (!found) return; + e.preventDefault(); + e.stopImmediatePropagation(); + flash(found.range); + const r = found.ref; + send('bibleapp:scripture', { book: r.book, chapter: r.chapter, verse: r.verse, verseEnd: r.verseEnd, canonical: r.canonical, translation: found.translation }); +}, true); + +// ------------------------------------------------------------------ the booth follower's copy +/* + * The booth screen re-renders the stage rather than receiving video (follower-link.js), so the page goes + * there as DOM. Only while a booth is linked — the app turns this on and off. Scripts are stripped (the + * booth must show THIS page, not re-run its search and get different results), and everything a script + * would have put in place is baked in: form values, which live in properties rather than attributes, and + * CSS-in-JS rules, which live in the CSSOM rather than in the '; + 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 (_) {} + }, + 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 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(); +})(); diff --git a/follower-link.js b/follower-link.js index bb52913..9ede65c 100644 --- a/follower-link.js +++ b/follower-link.js @@ -82,8 +82,20 @@ export function attachFollowerLink(server, opts = {}) { if (msg && msg.passageKey && !Array.isArray(msg.strokes) && lastStrokes.has(msg.passageKey)) { msg.strokes = lastStrokes.get(msg.passageKey); } + // The Browser tab's page (tens of KB of HTML) rides along only when it changed, exactly like a + // stroke list — so the retained copy gets it back here, or a screen joining mid-service would show + // the Browser tab blank until the page next changed. + if (msg && msg.browser && typeof msg.browser.html !== 'string' && lastBrowser && lastBrowser.ver === msg.browser.domVer) { + msg.browser.html = lastBrowser.html; + } return msg; } + let lastBrowser = null; // { ver, html } — the newest Browser-tab page the stage published + function rememberBrowser(msg) { + if (msg && msg.browser && typeof msg.browser.html === 'string' && msg.browser.domVer) { + lastBrowser = { ver: msg.browser.domVer, html: msg.browser.html }; + } + } /* * Only the machine this server runs on may publish. The stage renderer connects to its own server @@ -141,6 +153,7 @@ export function attachFollowerLink(server, opts = {}) { // A new stage session: nothing the previous one published (its ink, its passage) may be handed to // a screen that joins now. The new publisher's first snapshot arrives within a tick. lastStrokes.clear(); + lastBrowser = null; snapshot = null; log(`${marked ? 'stage renderer' : 'local publisher (unmarked)'} connected`); } else { @@ -177,6 +190,7 @@ export function attachFollowerLink(server, opts = {}) { if (msg && msg.type === 'snapshot') { msg.seq = ++seq; remember(msg); + rememberBrowser(msg); // Live subscribers already hold the strokes; only the RETAINED copy is completed. fanOut(JSON.stringify(msg)); snapshot = JSON.stringify(complete(msg)); diff --git a/follower-publish.js b/follower-publish.js index ef16cac..3854c98 100644 --- a/follower-publish.js +++ b/follower-publish.js @@ -55,7 +55,8 @@ lastScroll: null, lastKey: null, // the passage whose stroke list was last sent (see buildSnapshot) liveActive: false, - rafId: null, lastPumpAt: 0, lastZoom: '' + rafId: null, lastPumpAt: 0, lastZoom: '', + browserVer: 0, lastBrowserY: null, lastBrowserInputs: '' // the Browser tab's page (see buildSnapshot) }; function app() { return window.bibleApp || null; } @@ -208,6 +209,22 @@ if (html != null) snap.prayer = html; } else if (t.mode === 'ask') { snap.ask = b ? b.askMessages(t.id) : []; + } else if (t.mode === 'browser' && window.__browserTab) { + /* + * The Browser tab's page, as DOM with its scripts stripped (browser-tab.js) — the booth shows THIS + * page rather than loading the site itself, which would re-run the search and could put different + * results on the program feed. The HTML is tens of KB, so like the stroke list it rides along only + * when the page changed, the tab changed, or on a forced full republish; the relay completes its + * retained copy for screens that join later (follower-link.js). Scroll and form fields are small. + */ + var bs = window.__browserTab.stageState(t.id); + if (bs) { + snap.browser = bs; + if (bs.domVer && (bs.domVer !== S.browserVer || S.lastSnapshot === '')) { + var bh = window.__browserTab.stageHtml(t.id); + if (bh && bh.ver === bs.domVer) { snap.browser.html = bh.html; S.browserVer = bs.domVer; } + } + } } // The notes tab tracks which point it is on; harmless elsewhere. if (typeof t.notesCurrentIndex === 'number') snap.notesIndex = t.notesCurrentIndex; @@ -241,6 +258,17 @@ if (top !== S.lastScroll) { S.lastScroll = top; send({ type: 'scroll', tabId: t.id, scrollTop: top }); } } + // The Browser tab's page: its scroll per frame (coarse scroll makes text lurch, as above), and its form + // fields as they are typed into — a few bytes a keystroke, never the whole page again. + if (t.mode === 'browser' && window.__browserTab) { + var bst = window.__browserTab.stageState(t.id); + if (bst) { + if (bst.scrollY !== S.lastBrowserY) { S.lastBrowserY = bst.scrollY; send({ type: 'browserScroll', tabId: t.id, y: bst.scrollY }); } + var iv = bst.inputs ? JSON.stringify(bst.inputs) : ''; + if (iv !== S.lastBrowserInputs) { S.lastBrowserInputs = iv; if (bst.inputs) send({ type: 'browserInputs', tabId: t.id, values: bst.inputs }); } + } + } + // Maps pinch-zoom / pan, per frame while it changes (a pinch is as continuous as a stroke). var car = b.carousel(t.id); if (car && car.zoom) { @@ -292,6 +320,8 @@ * Defined on every copy of the app; both are no-ops while this copy is not publishing. */ window.__followerPublish = { + // browser-tab.js asks this to decide whether the page should bother publishing its DOM at all. + isOpen: function () { return !!S.open; }, invalidate: function () { S.strokeFingerprint = ''; S.lastKey = null; S.lastSnapshot = ''; if (S.open) { try { tick(); } catch (_) {} } }, clearAll: function () { if (S.open) send({ type: 'clearAll', ts: Date.now() }); this.invalidate(); } }; diff --git a/follower-view.js b/follower-view.js index 39d2e2b..00f1d0f 100644 --- a/follower-view.js +++ b/follower-view.js @@ -265,6 +265,8 @@ if (msg.carousel) applyCarousel(local.id, msg.carousel); if (typeof msg.prayer === 'string' && b) b.setPrayerHtml(local.id, msg.prayer); if (msg.ask && b) b.setAskMessages(local.id, msg.ask); + // The Browser tab: the stage's copy of its page (browser-tab.js never loads the site here). + if (msg.browser && window.__browserTab) window.__browserTab.applyFollower(local.id, msg.browser); } function activeLocalTabId() { @@ -315,6 +317,8 @@ if (msg.type === 'commit') return onCommit(msg); if (msg.type === 'scroll') { S.stats.scrolls++; return applyScroll(activeLocalTabId(), msg.scrollTop); } if (msg.type === 'zoom') return onZoom(msg); + if (msg.type === 'browserScroll') { S.stats.scrolls++; if (window.__browserTab) window.__browserTab.applyFollowerScroll(activeLocalTabId(), msg.y); return; } + if (msg.type === 'browserInputs') { if (window.__browserTab) window.__browserTab.applyFollowerInputs(activeLocalTabId(), msg.values); return; } } // ------------------------------------------------------------------ link diff --git a/index.html b/index.html index f7f7166..c843001 100644 --- a/index.html +++ b/index.html @@ -210,5 +210,8 @@ + + \ No newline at end of file diff --git a/main.cjs b/main.cjs index bf2ac0e..2b05063 100644 --- a/main.cjs +++ b/main.cjs @@ -191,6 +191,63 @@ async function startLocalHttpServer() { } } +/* + * The Browser tab (browser-tab.js) shows a third-party web page inside the stage window, in an Electron + * . A page from the internet gets none of the app's powers, whatever the renderer asked for: + * + * - Its preferences are FORCED here, not trusted from the tag: no Node, context isolation, sandbox, + * and the one preload that serves the Bible app (browser-guest-preload.cjs). Only the Browser tab's + * own session may be used, and only http(s) pages may load. + * - Nothing it does can put anything else on the stage: new windows open in the same view, other URL + * schemes (mailto:, file:, custom apps) are refused, permission prompts (camera, notifications, + * location, fullscreen…) are denied without asking, downloads are cancelled, and pinch-zoom is off + * so a stray two-finger touch cannot blow the page up in front of the room. + * - It identifies as plain Chrome. Some sites refuse a browser that says "Electron"; this is Chrome's + * engine, so the claim is accurate. + */ +const BROWSER_TAB_PARTITION = 'persist:browser-tab'; +const isWebUrl = (u) => /^https?:\/\//i.test(String(u || '')); +function secureBrowserTab(win) { + const { session } = require('electron'); + const ses = session.fromPartition(BROWSER_TAB_PARTITION); + // Electron's agent is "... (KHTML, like Gecko) / Chrome/x Electron/y Safari/z": keep only Chrome's. + try { ses.setUserAgent(ses.getUserAgent().replace(/(\(KHTML, like Gecko\))\s+.*?\s+(Chrome\/)/, '$1 $2').replace(/\s+Electron\/\S+/i, '')); } catch (_) {} + ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); + ses.setPermissionCheckHandler(() => false); + ses.on('will-download', (e) => { try { e.preventDefault(); } catch (_) {} }); + + win.webContents.on('will-attach-webview', (event, webPreferences, params) => { + if (params.partition !== BROWSER_TAB_PARTITION || !isWebUrl(params.src)) { + console.warn('[BROWSER TAB] refused a webview:', params.partition, params.src); + event.preventDefault(); + return; + } + delete webPreferences.preloadURL; + webPreferences.preload = path.join(__dirname, 'browser-guest-preload.cjs'); + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = true; + webPreferences.sandbox = true; + webPreferences.webSecurity = true; + webPreferences.backgroundThrottling = false; // a page in the background keeps loading, ready when shown + }); + + win.webContents.on('did-attach-webview', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + if (isWebUrl(url)) { try { contents.loadURL(url); } catch (_) {} } + return { action: 'deny' }; + }); + contents.on('will-navigate', (e, url) => { if (!isWebUrl(url)) e.preventDefault(); }); + // A page's "Leave site?" guard would otherwise silently cancel Reload and Start page — the very + // buttons used to recover the tab in front of the room. They always go through. + contents.on('will-prevent-unload', (e) => { try { e.preventDefault(); } catch (_) {} }); + contents.on('will-redirect', (e, url) => { if (!isWebUrl(url)) e.preventDefault(); }); + const noPinch = () => { try { contents.setVisualZoomLevelLimits(1, 1); } catch (_) {} }; + noPinch(); + contents.on('did-finish-load', noPinch); + }); +} + async function createWindow () { const win = new BrowserWindow({ width: 1200, @@ -205,6 +262,8 @@ async function createWindow () { webSecurity: true, // Same-origin policy ON. App content is served over http://localhost, // so file:// access is unnecessary; external APIs used are CORS-enabled. backgroundThrottling: false, // never throttle timers/paint when idle — first tap always registers + // The Browser tab's pages (browser-tab.js). Every is locked down in will-attach-webview below. + webviewTag: true, preload: path.join(__dirname, 'preload.cjs'), devTools: true // Enable DevTools for debugging } @@ -256,6 +315,8 @@ async function createWindow () { // Build a minimal Help menu with Content Manager and About options try { setupAppMenu(win); } catch (_) {} + try { secureBrowserTab(win); } catch (e) { console.warn('[BROWSER TAB] setup failed:', e && e.message); } + // Start the embedded server and load the app via HTTP so relative API routes work await startLocalHttpServer(); diff --git a/mirror-stream.js b/mirror-stream.js index 59a72ed..79fdc1b 100644 --- a/mirror-stream.js +++ b/mirror-stream.js @@ -49,6 +49,9 @@ try { var app = window.bibleApp; var id = app && app.activeTabId; + // The Browser tab scrolls a web page, not a reading area; browser-tab.js reports it in the same units. + var bt = (id && window.__browserTab && window.__browserTab.mirrorScrollState) ? window.__browserTab.mirrorScrollState(id) : null; + if (bt) { tickScroll = bt.sc; tickScrollMax = bt.sm; return; } var ra = id ? document.getElementById('readingArea' + id) : null; if (ra && ra.clientHeight > 0 && window.innerHeight > 0) { tickScroll = ra.scrollTop / window.innerHeight; diff --git a/presentation-tools.js b/presentation-tools.js index e89ebab..afb9d58 100644 --- a/presentation-tools.js +++ b/presentation-tools.js @@ -482,7 +482,9 @@ // Laser/spotlight follow the SAME rules as pen/highlighter: only on tabs where drawing is // allowed. On other tabs the full-screen overlay would also swallow every tap (e.g. the Ask // tab's mic button), so it must never be active there. - var NO_DRAW_MODES = ['announcements', 'graphics', 'maps', 'prayer', 'ask']; + // Browser too: its web page takes every touch (that is how it scrolls and is typed into), so an overlay + // here would only ever be in the way. + var NO_DRAW_MODES = ['announcements', 'graphics', 'maps', 'browser', 'prayer', 'ask']; function activeTabMode() { try { var a = window.bibleApp; diff --git a/quick-dock.js b/quick-dock.js index e945a0b..c624b5e 100644 --- a/quick-dock.js +++ b/quick-dock.js @@ -271,9 +271,9 @@ } function sync() { if (!dock) return; - // While floating, hide on tabs where drawing tools don't apply (media + ask + prayer); reappear (same spot) elsewhere. + // While floating, hide on tabs where drawing tools don't apply (media + browser + ask + prayer); reappear (same spot) elsewhere. var mode = activeTabMode(); - var HIDE_ON = ['announcements', 'graphics', 'maps', 'ask', 'prayer']; + var HIDE_ON = ['announcements', 'graphics', 'maps', 'browser', 'ask', 'prayer']; var show = getState() === 'floating' && HIDE_ON.indexOf(mode) === -1; dock.style.display = show ? 'flex' : 'none'; if (!show) return; diff --git a/remote-state.js b/remote-state.js index 3d37ecd..cd500f3 100644 --- a/remote-state.js +++ b/remote-state.js @@ -22,7 +22,7 @@ var lastSent = 0; function modeLabel(mode) { - var map = { bible: 'Bible', announcements: 'Announce', graphics: 'Graphics', maps: 'Maps', whiteboard: 'Whiteboard', notes: 'Notes', prayer: 'Prayer', ask: 'Ask (AI)', media: 'Media', alpha: 'Alpha', ppsync: 'PP Sync' }; + var map = { bible: 'Bible', announcements: 'Announce', graphics: 'Graphics', maps: 'Maps', browser: 'Browser', whiteboard: 'Whiteboard', notes: 'Notes', prayer: 'Prayer', ask: 'Ask (AI)', media: 'Media', alpha: 'Alpha', ppsync: 'PP Sync' }; return map[mode] || (mode ? mode.charAt(0).toUpperCase() + mode.slice(1) : 'Tab'); } @@ -92,6 +92,7 @@ var title = modeLabel(t.mode); if (t.mode === 'bible' && t.book) title = t.book + ' ' + (t.chapter || 1); if (t.ppsync && t.book) title = 'PP Sync: ' + t.book + ' ' + (t.chapter || 1); + if (t.mode === 'browser' && t.browserLabel) title = 'Browser: ' + t.browserLabel; /* * book/chapter/verse/translation are structured fields, not just the display title. The admin * console needs them to (a) clone the live TV state into its private copy exactly and (b) diff diff --git a/remote.html b/remote.html index d4dd52d..84af5fe 100644 --- a/remote.html +++ b/remote.html @@ -1345,6 +1345,30 @@ momentumRaf = requestAnimationFrame(step); } + /* + * Start a scroll gesture. Two fingers anywhere; on the Browser tab ONE finger too, once it has + * travelled — there is nothing to draw on a web page, and one finger is how the page scrolls on the + * TV itself. A finger that does not travel is still a tap (mirror_tap), so buttons, the search box + * and scripture references stay pressable from the phone. + */ + var BROWSER_SCROLL_SLOP = 0.012; // fraction of the picture: past this, a one-finger touch is a drag + function beginScroll(touches, oneFinger) { + scroll.active = true; + scroll.oneFinger = !!oneFinger; + scroll.lastY = avgY(touches); + scroll.total = 0; + scroll.seq = 0; + scroll.gesture = ++gestureCounter; + scrollAnchor = wanted; // the finger takes over from here (null until a stamped frame) + scrollSpeed = 0; + scroll.samples = [{ t: Date.now(), y: scroll.lastY }]; + redrawStrokes(); // synchronous: wipe a cancelled live stroke off the mirror right now + paintPreview(); + } + function hintText() { + return activeTabMode() === 'browser' ? 'tap to press \u00b7 drag to scroll' : 'tap to press \u00b7 1 finger draws \u00b7 2 fingers scroll'; + } + pad.addEventListener('touchstart', function (e) { e.preventDefault(); var wasCoasting = !!momentumRaf; @@ -1371,16 +1395,7 @@ // the app discards it the same way it discards a two-finger-cancelled stroke on the TV. if (stroke.sent) queueDraw('cancel', []); resetStroke(); - scroll.active = true; - scroll.lastY = avgY(e.touches); - scroll.total = 0; - scroll.seq = 0; - scroll.gesture = ++gestureCounter; - scrollAnchor = wanted; // the finger takes over from here (null until a stamped frame) - scrollSpeed = 0; - scroll.samples = [{ t: Date.now(), y: scroll.lastY }]; - redrawStrokes(); // synchronous: wipe a cancelled live stroke off the mirror right now - paintPreview(); + beginScroll(e.touches, false); return; } if (scroll.active) return; @@ -1390,9 +1405,9 @@ var mode = activeTabMode(); var tool = tvToolNow(); // Match the TV's own refusals up front, so the preview never shows a line the TV will not draw: - // no drawing on announcement/graphics/maps tabs, no drawing with a non-drawing tool, and a + // no drawing on announcement/graphics/maps/browser tabs, no drawing with a non-drawing tool, and a // stroke must START inside the text area (the TV ignores ones that don't). - var blocked = (mode === 'announcements' || mode === 'graphics' || mode === 'maps') + var blocked = (mode === 'announcements' || mode === 'graphics' || mode === 'maps' || mode === 'browser') || !(tool === 'pen' || tool === 'highlight' || tool === 'erase'); var r = tvRect(); if (!blocked && r && (p.x < r.x || p.x > r.x + r.w || p.y < r.y || p.y > r.y + r.h)) blocked = true; @@ -1408,7 +1423,7 @@ if (swallowGesture) return; var now = Date.now(); if (scroll.active) { - if (e.touches.length < 2) return; + if (e.touches.length < (scroll.oneFinger ? 1 : 2)) return; var y = avgY(e.touches); var movedPx = y - scroll.lastY; scroll.lastY = y; @@ -1426,6 +1441,13 @@ var p = norm(e.touches[0]); if (!p) return; if (stroke.blocked) { + if (activeTabMode() === 'browser') { + var f0 = stroke.all[0]; + if (Math.abs(p.x - f0.x) < BROWSER_SCROLL_SLOP && Math.abs(p.y - f0.y) < BROWSER_SCROLL_SLOP) return; // still a tap + resetStroke(); + beginScroll(e.touches, true); + return; + } // Tell the operator once WHY nothing is happening instead of silently eating the gesture. if (!stroke.warned) { stroke.warned = true; @@ -1512,7 +1534,7 @@ hint.textContent = 'Splash is on — tap the screen to hide it'; hint.style.opacity = '1'; } else if (!hintIntro) { - hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll'; + hint.textContent = hintText(); hint.style.opacity = '0'; } } @@ -1536,7 +1558,7 @@ view.classList.remove('hidden'); setStatus('connecting…', ''); hintIntro = true; - hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll'; + hint.textContent = hintText(); hint.style.opacity = '1'; setTimeout(function () { hintIntro = false; updateTvHint(); }, 2600); updateTvHint(); diff --git a/scripture-scan.js b/scripture-scan.js index d7a3c8f..dc8b81a 100644 --- a/scripture-scan.js +++ b/scripture-scan.js @@ -9,6 +9,9 @@ * scanText(text) — the FIRST "Book chapter:verse(-verse)" inside free prose ("…as it says in * Rev. 21:4, God will…"). Needs the colon (or "Heb 11.4"), so a chapter-only * mention in a sentence never fires — too ambiguous on stage. + * scanAll(text) — EVERY such reference, each with the [start, end) it occupies in the text as + * given. Same rules as scanText; used by the Browser tab to find the reference + * under a finger on a web page. * * Book names go through window.BibleBooks (book-aliases.js), which refuses to guess between two books. * One extra rule here: a one/two-letter alias found in prose ("is", "am", "re") only counts when it is @@ -100,6 +103,49 @@ return null; } + /* + * scanText's rules, applied to the whole text, keeping WHERE each reference sits. The normalisation is + * character-for-character (a dash stays one dash, every odd space stays one space — nothing collapses, + * nothing is trimmed), so start/end index the caller's own string and map straight back onto the text + * nodes of a web page. `start` skips punctuation glued to the book ("(John 3:16" starts at "John"). + */ + function scanAll(text) { + var B = books(); + var s = String(text || '').replace(/[‐-―−]/g, '-').replace(/[  -​\s]/g, ' '); + var out = []; + if (!B || !s.trim()) return out; + var re = new RegExp(CHAPTER_VERSE.source, 'g'); // never share scanText's lastIndex + var m; + while ((m = re.exec(s))) { + var before = s.slice(0, m.index).replace(/[\s"'“”‘’(\[]+$/, ''); + var found = null; + // Longest run first, exactly as scanText: "1 John" before "John", "Song of Songs" before "Songs". + for (var n = 4; n >= 1 && !found; n--) { + var tail = before.match(new RegExp('(\\S+(?:\\s+\\S+){' + (n - 1) + '})$')); + if (!tail) continue; + var run = tail[1].split(/\s+/); + var head = run[0].replace(/^[^A-Za-z0-9]+/, ''); + if (!(/^[A-Za-z]/.test(head) || BOOK_NUMBER.test(head.replace(/[^A-Za-z0-9]/g, '')))) continue; + var book = resolveCandidate(run); + if (book) found = { book: book, start: tail.index + (run[0].length - head.length) }; + } + if (!found) continue; + var ref = makeRef(found.book, m[1], m[2], m[3] ? null : m[4]); + if (ref) out.push({ ref: ref, start: found.start, end: m.index + m[0].length }); + } + return out; + } + + // The translation printed right after a reference — "John 3:16 (NIV)", "Isaiah 41:10NIV" — or null. + var TRANSLATION_AFTER = new RegExp('^\\s*[-,:;]?\\s*[\\(\\[]?\\s*(' + TRANSLATIONS + ')(?![A-Za-z0-9])', 'i'); + function translationAfter(text, index) { + var m = String(text || '').slice(index || 0).match(TRANSLATION_AFTER); + return m ? m[1].toUpperCase() : null; + } + function isTranslation(code) { + return new RegExp('^(?:' + TRANSLATIONS + ')$', 'i').test(String(code || '').trim()); + } + function parseField(text) { var B = books(); var t = stripTranslation(text); @@ -147,7 +193,8 @@ return null; } - var API = { parseField: parseField, scanText: scanText, parseSlide: parseSlide, stripTranslation: stripTranslation }; + var API = { parseField: parseField, scanText: scanText, scanAll: scanAll, parseSlide: parseSlide, stripTranslation: stripTranslation, + translationAfter: translationAfter, isTranslation: isTranslation }; if (typeof module === 'object' && module.exports) module.exports = API; root.ScriptureScan = API; })(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/server.js b/server.js index 23e6343..d392eca 100644 --- a/server.js +++ b/server.js @@ -956,7 +956,7 @@ const oscStateUiCache = { * --------------------------------------------------------------------------------------------------- */ const OSC_TAB_KINDS = ['bible', 'sync', 'whiteboard', 'notes', 'media', 'alpha', - 'announcements', 'graphics', 'maps', 'prayer', 'ask']; + 'announcements', 'graphics', 'maps', 'browser', 'prayer', 'ask']; const OSC_TAB_SLOTS = 16; /* @@ -2250,6 +2250,29 @@ function startOscListener() { sendOscFeedback(msg, info, 'show_maps', !!result, result ? null : 'Renderer invoker returned false'); return; } + /* + * The Browser tab (browser-tab.js). /bibleapp/browser shows it, like /bibleapp/maps. reload and home + * are the booth's recovery buttons for a live web page in front of the room: a page that hung, or + * a tap that wandered off to another site, is put right from Companion without walking to the TV. + */ + if (norm === '/bibleapp/browser' || norm === '/bibleapp/showbrowser') { + if (!tokenOk) { sendOscFeedback(msg, info, 'show_browser', false, 'Token validation failed'); return; } + const result = invokeRenderer('showBrowser'); + sendOscFeedback(msg, info, 'show_browser', !!result, result ? null : 'Renderer invoker returned false'); + return; + } + if (norm === '/bibleapp/browser/reload') { + if (!tokenOk) { sendOscFeedback(msg, info, 'browser_reload', false, 'Token validation failed'); return; } + const result = invokeRenderer('browserReload'); + sendOscFeedback(msg, info, 'browser_reload', !!result, result ? null : 'Renderer invoker returned false'); + return; + } + if (norm === '/bibleapp/browser/home') { + if (!tokenOk) { sendOscFeedback(msg, info, 'browser_home', false, 'Token validation failed'); return; } + const result = invokeRenderer('browserHome'); + sendOscFeedback(msg, info, 'browser_home', !!result, result ? null : 'Renderer invoker returned false'); + return; + } if (norm === '/bibleapp/chapter/prev' || norm === '/bibleapp/prev_chapter') { if (!tokenOk) { sendOscFeedback(msg, info, 'chapter_prev', false, 'Token validation failed'); return; } const result = invokeRenderer('chapterPrev'); @@ -3882,6 +3905,7 @@ const server = http.createServer(async (req, res) => { 'switchToMode', 'switchTabId', 'switch_tab_left', 'switch_tab_right', 'new_bible_tab', 'new_whiteboard_tab', 'new_notes_tab', 'close_tab', 'close_active_tab', 'showMaps', + 'showBrowser', 'browserReload', 'browserHome', // Presentation 'splash_show', 'splash_hide', 'splashToggle', 'presentOff', 'laser', 'spotlight', 'redLetterToggle', @@ -4320,7 +4344,7 @@ const server = http.createServer(async (req, res) => { // Safe subset of control:perform the remote is allowed to trigger. // clear_all + the four scroll actions are handled in app.js; they were unreachable from the // phone until they were listed here. - const REMOTE_ACTIONS = new Set(['chapterPrev','chapterNext','gotoPassage','splash_show','splashToggle','presentOff','laser','spotlight','redLetterToggle','showMaps','switchToMode','switchTabId','tool','undo','redo','clear_current','clear_all','switch_tab_left','switch_tab_right','applyProfile','new_bible_tab','scroll_top','scroll_bottom','scroll_up_5','scroll_down_5','close_tab','mirror_scroll','mirror_draw','splash_hide','mirror_tap']); + const REMOTE_ACTIONS = new Set(['chapterPrev','chapterNext','gotoPassage','splash_show','splashToggle','presentOff','laser','spotlight','redLetterToggle','showMaps','showBrowser','browserReload','browserHome','switchToMode','switchTabId','tool','undo','redo','clear_current','clear_all','switch_tab_left','switch_tab_right','applyProfile','new_bible_tab','scroll_top','scroll_bottom','scroll_up_5','scroll_down_5','close_tab','mirror_scroll','mirror_draw','splash_hide','mirror_tap']); // Public (no PIN): lets the phone page show a friendly on/off state. // `v` is the app version: the phone remembers the version it booted against and reloads @@ -7702,10 +7726,14 @@ const server = http.createServer(async (req, res) => { res.end(`Server Error: ${error.code}`); } } else { - // Success - Set CSP headers to allow Google OAuth and pdf.js CDN/worker + // Success - Set CSP headers to allow Google OAuth and pdf.js CDN/worker. + // The Browser tab (browser-tab.js) adds three things. frame-src http(s): its is a frame as + // far as CSP is concerned, and it shows whatever page the operator set. style-src / font-src https: + // the booth follower shows that page as a script-free srcdoc copy, which INHERITS this policy, so the + // site's own stylesheets and web fonts must be allowed to load there. script-src is unchanged. const headers = { 'Content-Type': contentType, - 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com https://www.gstatic.com https://accounts.google.com https://cdn.jsdelivr.net; worker-src 'self' blob: https://cdn.jsdelivr.net; frame-src 'self' blob: https://accounts.google.com; connect-src 'self' https://www.googleapis.com https://accounts.google.com https://bible-api.com https://api.esv.org https://api.scripture.api.bible https://cdn.jsdelivr.net https://greekwordnet.chs.harvard.edu https://complete-study-bible.p.rapidapi.com https://*.rapidapi.com; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com;" + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com https://www.gstatic.com https://accounts.google.com https://cdn.jsdelivr.net; worker-src 'self' blob: https://cdn.jsdelivr.net; frame-src 'self' blob: https://accounts.google.com https: http:; connect-src 'self' https://www.googleapis.com https://accounts.google.com https://bible-api.com https://api.esv.org https://api.scripture.api.bible https://cdn.jsdelivr.net https://greekwordnet.chs.harvard.edu https://complete-study-bible.p.rapidapi.com https://*.rapidapi.com; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https:; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com https: data:;" }; // Disable caching for core app assets to ensure rebuilt changes are picked up if (/^text\/(?:html|css|javascript)$/.test(contentType)) { diff --git a/settings.html b/settings.html index 9eb1dde..074cfb2 100644 --- a/settings.html +++ b/settings.html @@ -829,7 +829,7 @@
/bibleapp/feedback/state/tab_active/<n> — 1 while the n-th tab on the row is showing (1–16).
/bibleapp/feedback/state/tab_kind/<kind> — 1 while a tab of that kind is showing:
- bible · sync · whiteboard · notes · media · alpha · announcements · graphics · maps · prayer · ask + bible · sync · whiteboard · notes · media · alpha · announcements · graphics · maps · browser · prayer · ask
+ +
+

Browser Tab

+

+ The Browser tab shows one web page, full screen in the tab: no address bar, bookmarks or scrollbars. + It loads when the app starts and keeps running while other tabs are showing, so it is ready the moment + you open it. Scroll it with a finger; tap a text box on the page and the app's own keyboard comes up to + type into it; tap a scripture reference on the page to open it in a new Bible tab. The booth follower + screen shows the same page, clean. +

+
+ + +
The page the tab opens. Leave blank for It Is Written. Changing it takes the tab to the new page.
+
+
+ + +
Auto lays the page out like a tablet and scales it to fill the width — on the portrait TV that is + about 200%. Pick a size if a site looks too small or too big.
+
+
+ +
Opens in the translation printed beside the reference (e.g. NIV) when that translation is switched on + here, otherwise in the default. Taps anywhere else work on the page as normal.
+
+
+ ✓ Saves automatically + +
+
+ The tab remembers where it was (a search, how far down) while the app is running, so a trip to Settings does + not lose it. Return to the start page clears that, as does the refresh button on the TV. + From the booth: OSC /bibleapp/browser/home and /bibleapp/browser/reload. +
+
+

Ask Tab (AI Assistant)

@@ -1002,6 +1053,14 @@
/bibleapp/close - Close active tab
/bibleapp/tab/left - Switch to previous tab
/bibleapp/tab/right - Switch to next tab
+
/bibleapp/maps - Show the Maps & Timelines tab
+
/bibleapp/browser - Show the Browser tab
+
+ +
Browser tab:
+
+
/bibleapp/browser/reload - Reload the page (if it hung or looks wrong)
+
/bibleapp/browser/home - Take the page back to its start page (Settings › Browser Tab)
Tools:
@@ -2027,6 +2086,7 @@ { mode: 'announcements', label: 'Announcements Tab', description: 'Image-based announcements display' }, { mode: 'graphics', label: 'Graphics Tab', description: 'Image-based graphics display' }, { mode: 'maps', label: 'Maps & Timelines Tab', description: 'Image gallery for maps and timelines' }, + { mode: 'browser', label: 'Browser Tab', description: 'A live web page, full screen in the tab with no browser bars (set it up under Browser Tab below)' }, { mode: 'whiteboard', label: 'Whiteboard Tab', description: 'Blank whiteboard for drawing' }, { mode: 'notes', label: 'Notes Tab', description: 'PDF notes with annotations' }, { mode: 'prayer', label: 'Prayer Tab', description: 'Text editor for prayers and notes' }, @@ -2791,6 +2851,43 @@ }).join('
'); } try { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadSplashTriggers); else loadSplashTriggers(); } catch (_) {} + + // ---- Browser Tab (browser-tab.js reads these from the same-origin localStorage) ---- + const BROWSER_TAB_DEFAULT_URL = 'https://it-is-written-waters-church.vercel.app/'; + function loadBrowserTabSettings() { + try { + document.getElementById('browserTabUrl').value = localStorage.getItem('browserTabUrl') || ''; + const z = localStorage.getItem('browserTabZoom') || 'auto'; + const sel = document.getElementById('browserTabZoom'); + sel.value = Array.from(sel.options).some(o => o.value === z) ? z : 'auto'; + document.getElementById('browserTabScriptureTaps').checked = localStorage.getItem('browserTabScriptureTaps') !== '0'; + } catch (_) {} + } + function saveBrowserTabSettings() { + try { + const urlEl = document.getElementById('browserTabUrl'); + let url = String(urlEl.value || '').trim(); + // "example.org/page" is what people type; the tab needs a full web address. + if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url.replace(/^\/+/, ''); + if (url) { + try { new URL(url); } catch (_) { showStatus('That start page is not a web address.', 'error'); return; } + } + const before = localStorage.getItem('browserTabUrl') || ''; + if (url) localStorage.setItem('browserTabUrl', url); else localStorage.removeItem('browserTabUrl'); + urlEl.value = url; + // A new start page means the tab should go there, not back to where it was on the old one. + if (url !== before) { try { sessionStorage.removeItem('browserTabSession'); } catch (_) {} } + localStorage.setItem('browserTabZoom', document.getElementById('browserTabZoom').value || 'auto'); + localStorage.setItem('browserTabScriptureTaps', document.getElementById('browserTabScriptureTaps').checked ? '1' : '0'); + showStatus('Browser tab saved — ' + (url || BROWSER_TAB_DEFAULT_URL), 'success'); + } catch (e) { showStatus('Could not save Browser tab settings: ' + e.message, 'error'); } + } + // Settings replaces the app in this same window, so the session the tab keeps is visible from here. + function browserTabBackToStart() { + try { sessionStorage.removeItem('browserTabSession'); } catch (_) {} + showStatus('The Browser tab will open its start page when you go back to the app.', 'success'); + } + try { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadBrowserTabSettings); else loadBrowserTabSettings(); } catch (_) {} window.addEventListener('storage', (ev) => { try { if (ev && ev.key === 'splashHistory') renderSplashHistory(); } catch (_) {} }); // ---- Follower screen (booth mirror → ProPresenter / ATEM) ---- @@ -3058,7 +3155,8 @@ 'redLetterEnabled', 'redLetterOverrides', 'popupVisibility', 'translationVisibility', 'wordPopupsEnabled', 'spotlightHalfPx', 'userLaserColor', 'toolWheelEnabled', 'toolWheelStyle', 'toolSnapBackSec', - 'idleSplashSeconds', 'fiveFingerSplash', 'ppsyncSeparateAnnotations'].forEach(function (k) { + 'idleSplashSeconds', 'fiveFingerSplash', 'ppsyncSeparateAnnotations', + 'browserTabUrl', 'browserTabZoom', 'browserTabScriptureTaps'].forEach(function (k) { var v = localStorage.getItem(k); if (v !== null) settings[k] = v; }); diff --git a/tool-wheel.js b/tool-wheel.js index a89d9dd..7d1322f 100644 --- a/tool-wheel.js +++ b/tool-wheel.js @@ -79,7 +79,7 @@ laser: '#laserToolBtn', spotlight: '#spotlightToolBtn' }; var FALLBACK_COLOR = { pen: '#ffff00', highlight: '#ffff00', erase: '#ffb6c1', laser: '#ffff00', spotlight: '#e6e6e6' }; - var NO_TOOL_MODES = ['announcements', 'graphics', 'maps', 'prayer', 'ask']; + var NO_TOOL_MODES = ['announcements', 'graphics', 'maps', 'browser', 'prayer', 'ask']; function log() { try { console.log.apply(console, ['[TOOL WHEEL]'].concat([].slice.call(arguments))); } catch (_) {} } function guards() { return window.__gestureGuards || null; }