Add the Browser tab: a live web page with no browser chrome

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 <webview> 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:25:15 -04:00
co-authored by Claude Opus 5.5
parent a0c3921edd
commit e7e527197f
20 changed files with 1780 additions and 60 deletions
+79
View File
@@ -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 `<webview>` — 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 `<canvas>`, 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.
+1 -1
View File
@@ -86,7 +86,7 @@ also goes out as 1/0 per tab, in two flavours:
| `/bibleapp/feedback/state/tab_kind/<kind>` | `1` while a tab of that **kind** is showing | | `/bibleapp/feedback/state/tab_kind/<kind>` | `1` while a tab of that **kind** is showing |
`<kind>` is one of: `bible` · `sync` · `whiteboard` · `notes` · `media` · `alpha` · `announcements` · `<kind>` 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_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 - Use **`tab_kind`** for a fixed button that asks "is he on the whiteboard?" — that one stays right
+21 -1
View File
@@ -361,7 +361,7 @@
// ---------------------------------------------------------------- catalogue data // ---------------------------------------------------------------- catalogue data
var TAB_MODES = [ var TAB_MODES = [
{ m: 'bible', t: 'Bible' }, { m: 'announcements', t: 'Announcements' }, { 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: 'whiteboard', t: 'Whiteboard' }, { m: 'notes', t: 'Notes' },
{ m: 'prayer', t: 'Prayer' }, { m: 'ask', t: 'Ask (AI)' }, { m: 'ppsync', t: 'ProPresenter Sync' }, { m: 'prayer', t: 'Prayer' }, { m: 'ask', t: 'Ask (AI)' }, { m: 'ppsync', t: 'ProPresenter Sync' },
{ m: 'media', t: 'Media' }, { m: 'alpha', t: 'Alpha' } { m: 'media', t: 'Media' }, { m: 'alpha', t: 'Alpha' }
@@ -502,6 +502,11 @@
btn('◀ Previous tab', 'sec', function () { pushAction('Previous tab', 'switch_tab_left'); }), btn('◀ Previous tab', 'sec', function () { pushAction('Previous tab', 'switch_tab_left'); }),
btn('Next tab ▶', 'sec', function () { pushAction('Next tab', 'switch_tab_right'); }) 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', [ 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('Bible', 'sec', function () { pushAction('New Bible tab', 'new_bible_tab'); }),
btn('Whiteboard', 'sec', function () { pushAction('New whiteboard tab', 'new_whiteboard_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)); 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', [ b.appendChild(row('Word popups', 'Double-tap a word to open its card', [
btn('On', 'sec', function () { pushSetting('Word popups ON', 'wordPopupsEnabled', 'true'); }), btn('On', 'sec', function () { pushSetting('Word popups ON', 'wordPopupsEnabled', 'true'); }),
btn('Off', 'sec', function () { pushSetting('Word popups OFF', 'wordPopupsEnabled', 'false'); }) btn('Off', 'sec', function () { pushSetting('Word popups OFF', 'wordPopupsEnabled', 'false'); })
+158 -30
View File
@@ -474,6 +474,10 @@ function getPassageKey(book, chapter, verse, verseEnd, translation, mode = 'bibl
const tabId = getActiveTabId(); const tabId = getActiveTabId();
return tabId ? `maps-annotations-${tabId}` : 'maps-annotations'; return tabId ? `maps-annotations-${tabId}` : 'maps-annotations';
} }
if (mode === 'browser') {
const tabId = getActiveTabId();
return tabId ? `browser-annotations-${tabId}` : 'browser-annotations';
}
if (mode === 'notes') { if (mode === 'notes') {
const tabId = getActiveTabId(); const tabId = getActiveTabId();
return `notes-annotations-${tabId}`; return `notes-annotations-${tabId}`;
@@ -2046,8 +2050,22 @@ function linkifyScriptureRefs(escapedHtml) {
} catch (_) { return 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 <webview> 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). // 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 { try {
const app = window.bibleApp; const app = window.bibleApp;
if (!app || !book || !chapter) return; if (!app || !book || !chapter) return;
@@ -2058,6 +2076,12 @@ function openPassageInNewTab(book, chapter, verse, verseEnd) {
tab.chapter = parseInt(chapter, 10) || 1; tab.chapter = parseInt(chapter, 10) || 1;
tab.verse = parseInt(verse, 10) || 1; tab.verse = parseInt(verse, 10) || 1;
tab.verseEnd = verseEnd ? (parseInt(verseEnd, 10) || null) : null; 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; tab.loaded = false;
// Scroll to the tapped verse once the chapter renders (the chapter opens AT the verse, // Scroll to the tapped verse once the chapter renders (the chapter opens AT the verse,
// not at the top). // not at the top).
@@ -2833,14 +2857,17 @@ export default class BibleApp {
} }
const tabContent = document.querySelector('.tab-content'); const tabContent = document.querySelector('.tab-content');
if (tabContent) tabContent.innerHTML = ''; if (tabContent) clearTabContentKeepingBrowser(tabContent);
this.tabs = []; this.tabs = [];
this.activeTabId = null; 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(() => { runAutomaticOperation(() => {
this.createAnnouncementsTab(); this.createAnnouncementsTab();
this.createGraphicsTab(); this.createGraphicsTab();
this.createMapsTab(); this.createMapsTab();
this.createBrowserTab();
this.createWhiteboardTab(); this.createWhiteboardTab();
this.createNotesTab(); this.createNotesTab();
this.createPrayerTab(); this.createPrayerTab();
@@ -3514,6 +3541,7 @@ export default class BibleApp {
// Create initial tabs automatically without recording them as user operations: // Create initial tabs automatically without recording them as user operations:
// 1) Announcements tab (leftmost; image-first view) // 1) Announcements tab (leftmost; image-first view)
// 2) Graphics tab (image carousel from Graphics/ directory) // 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) // 3) Whiteboard tab (not active)
// 4) Notes tab // 4) Notes tab
// 5) Prayer tab // 5) Prayer tab
@@ -3523,6 +3551,7 @@ export default class BibleApp {
this.createAnnouncementsTab(); this.createAnnouncementsTab();
this.createGraphicsTab(); this.createGraphicsTab();
this.createMapsTab(); this.createMapsTab();
this.createBrowserTab();
this.createWhiteboardTab(); this.createWhiteboardTab();
this.createNotesTab(); this.createNotesTab();
this.createPrayerTab(); this.createPrayerTab();
@@ -5266,6 +5295,29 @@ export default class BibleApp {
this._insertGeneralTab(tab); this._insertGeneralTab(tab);
this.renderTabs(); 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() { createWhiteboardTab() {
if (!isAutomaticOperation) { if (!isAutomaticOperation) {
pulseCompanionActionFeedback('new_whiteboard_tab'); pulseCompanionActionFeedback('new_whiteboard_tab');
@@ -5545,8 +5597,12 @@ export default class BibleApp {
// }); // });
} }
// Create draggable popup keyboard // Create draggable popup keyboard.
createPrayerKeyboard(keyboardEl, textEl, tabId) { // `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, // 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 — // Backspace tails the Z-row, and Space + Enter share the action row —
// matching a real keyboard so muscle memory works. // matching a real keyboard so muscle memory works.
@@ -5592,6 +5648,7 @@ export default class BibleApp {
// Build keyboard HTML with formatting toolbar // Build keyboard HTML with formatting toolbar
let keyboardHTML = '<div class="prayer-keyboard-header">'; let keyboardHTML = '<div class="prayer-keyboard-header">';
keyboardHTML += '<span class="prayer-keyboard-title">Keyboard</span>'; keyboardHTML += '<span class="prayer-keyboard-title">Keyboard</span>';
if (!sink) {
keyboardHTML += '<div class="prayer-format-toolbar">'; keyboardHTML += '<div class="prayer-format-toolbar">';
keyboardHTML += '<button class="prayer-format-btn" data-format="bold" title="Bold (Ctrl+B)"><b>B</b></button>'; keyboardHTML += '<button class="prayer-format-btn" data-format="bold" title="Bold (Ctrl+B)"><b>B</b></button>';
keyboardHTML += '<button class="prayer-format-btn" data-format="italic" title="Italic (Ctrl+I)"><i>I</i></button>'; keyboardHTML += '<button class="prayer-format-btn" data-format="italic" title="Italic (Ctrl+I)"><i>I</i></button>';
@@ -5607,6 +5664,7 @@ export default class BibleApp {
// Paste icon (clipboard) // Paste icon (clipboard)
keyboardHTML += '<button class="prayer-format-btn" data-format="paste" title="Paste (Ctrl+V)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></button>'; keyboardHTML += '<button class="prayer-format-btn" data-format="paste" title="Paste (Ctrl+V)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></button>';
keyboardHTML += '</div>'; keyboardHTML += '</div>';
}
keyboardHTML += `<button class="prayer-keyboard-close" onclick="this.closest('.prayer-keyboard').style.display='none'" aria-label="Close keyboard">${CLOSE_GLYPH}</button>`; keyboardHTML += `<button class="prayer-keyboard-close" onclick="this.closest('.prayer-keyboard').style.display='none'" aria-label="Close keyboard">${CLOSE_GLYPH}</button>`;
keyboardHTML += '</div>'; keyboardHTML += '</div>';
keyboardHTML += '<div class="prayer-keyboard-body">'; keyboardHTML += '<div class="prayer-keyboard-body">';
@@ -5701,12 +5759,12 @@ export default class BibleApp {
// Remove any previously-registered prayer-keyboard document listeners so they don't // 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). // stack on every re-render (the keyboard DOM is rebuilt each time the prayer tab renders).
if (this._prayerKbHandlers) { if (this[kbSlot]) {
document.removeEventListener('mousemove', this._prayerKbHandlers.mousemove); document.removeEventListener('mousemove', this[kbSlot].mousemove);
document.removeEventListener('touchmove', this._prayerKbHandlers.touchmove); document.removeEventListener('touchmove', this[kbSlot].touchmove);
document.removeEventListener('mouseup', this._prayerKbHandlers.mouseup); document.removeEventListener('mouseup', this[kbSlot].mouseup);
document.removeEventListener('touchend', this._prayerKbHandlers.touchend); document.removeEventListener('touchend', this[kbSlot].touchend);
if (this._prayerKbHandlers.selectionchange) document.removeEventListener('selectionchange', this._prayerKbHandlers.selectionchange); if (this[kbSlot].selectionchange) document.removeEventListener('selectionchange', this[kbSlot].selectionchange);
} }
const onPrayerMouseMove = (e) => { const onPrayerMouseMove = (e) => {
if (isDragging) { if (isDragging) {
@@ -5727,7 +5785,7 @@ export default class BibleApp {
document.addEventListener('touchmove', onPrayerTouchMove); document.addEventListener('touchmove', onPrayerTouchMove);
document.addEventListener('mouseup', onPrayerMouseUp); document.addEventListener('mouseup', onPrayerMouseUp);
document.addEventListener('touchend', onPrayerTouchEnd); 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). // Three-state shift: 'off' | 'on' | 'locked' (caps lock).
// 'off' -> letters lowercase, numbers as-is // 'off' -> letters lowercase, numbers as-is
@@ -5812,11 +5870,11 @@ export default class BibleApp {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const key = btn.dataset.key; const key = btn.dataset.key;
if (key === 'backspace') { if (key === 'backspace') {
document.execCommand('delete', false); if (sink) sink.key('Backspace'); else document.execCommand('delete', false);
} else if (key === 'enter') { } else if (key === 'enter') {
insertTextInto('\n'); if (sink) sink.key('Enter'); else insertTextInto('\n');
} else if (key === 'tab') { } else if (key === 'tab') {
insertTextInto('\t'); if (sink) sink.key('Tab'); else insertTextInto('\t');
} else if (key === 'shift') { } else if (key === 'shift') {
const now = Date.now(); const now = Date.now();
if (shiftState === 'locked') { if (shiftState === 'locked') {
@@ -5839,15 +5897,27 @@ export default class BibleApp {
} else if (/[0-9]/.test(key) && numberShiftActive() && btn.dataset.shift) { } else if (/[0-9]/.test(key) && numberShiftActive() && btn.dataset.shift) {
textToInsert = 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. // Auto-release momentary Shift after any character key — but never auto-release Caps Lock.
if (shiftState === 'on') { if (shiftState === 'on') {
setShiftState('off'); 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 // Handle formatting button clicks
keyboardEl.querySelectorAll('.prayer-format-btn').forEach(btn => { keyboardEl.querySelectorAll('.prayer-format-btn').forEach(btn => {
@@ -5958,7 +6028,7 @@ export default class BibleApp {
} }
}; };
document.addEventListener('selectionchange', onPrayerSelectionChange); 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 // Keyboard shortcuts for formatting and copy/paste
@@ -5993,6 +6063,7 @@ export default class BibleApp {
if (parsed.ask === undefined) parsed.ask = true; if (parsed.ask === undefined) parsed.ask = true;
if (parsed.graphics === undefined) parsed.graphics = true; if (parsed.graphics === undefined) parsed.graphics = true;
if (parsed.maps === undefined) parsed.maps = true; if (parsed.maps === undefined) parsed.maps = true;
if (parsed.browser === undefined) parsed.browser = true;
if (parsed.ppsync === undefined) parsed.ppsync = true; if (parsed.ppsync === undefined) parsed.ppsync = true;
} }
return parsed; return parsed;
@@ -6006,6 +6077,7 @@ export default class BibleApp {
announcements: true, announcements: true,
graphics: true, graphics: true,
maps: true, maps: true,
browser: true,
whiteboard: true, whiteboard: true,
notes: true, notes: true,
prayer: true, prayer: true,
@@ -6173,6 +6245,15 @@ export default class BibleApp {
<rect x="2" y="3" width="20" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/> <rect x="2" y="3" width="20" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<text x="12" y="16" text-anchor="middle" font-size="14" font-family="Georgia, serif" fill="currentColor">M</text> <text x="12" y="16" text-anchor="middle" font-size="14" font-family="Georgia, serif" fill="currentColor">M</text>
</svg>`; </svg>`;
} 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 = `<svg class="browser-tab-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<rect x="2" y="3" width="20" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<circle cx="12" cy="12" r="5.6" fill="none" stroke="currentColor" stroke-width="1.3"/>
<ellipse cx="12" cy="12" rx="2.4" ry="5.6" fill="none" stroke="currentColor" stroke-width="1.1"/>
<line x1="6.4" y1="12" x2="17.6" y2="12" stroke="currentColor" stroke-width="1.1"/>
</svg>`;
} else if (tab.mode === 'prayer') { } else if (tab.mode === 'prayer') {
iconHTML = `<svg class="prayer-tab-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"> iconHTML = `<svg class="prayer-tab-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<rect x="2" y="3" width="20" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/> <rect x="2" y="3" width="20" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
@@ -7085,13 +7166,15 @@ export default class BibleApp {
// The row just changed shape, so "tab 2 of 5" may have too (OSC information line). // The row just changed shape, so "tab 2 of 5" may have too (OSC information line).
try { scheduleCompanionActiveTabFeedback(); } catch (_) {} try { scheduleCompanionActiveTabFeedback(); } catch (_) {}
// Show or hide the Browser tab's page to match (and give new Browser tabs a page, closed ones none).
try { if (window.__browserTab) window.__browserTab.sync(); } catch (_) {}
} }
toggleTabMode(tabId) { toggleTabMode(tabId) {
const tab = this.tabs.find(t => t.id === tabId); const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return; if (!tab) return;
// Prevent toggling mode for fixed-mode tabs (whiteboard/notes/alpha/announcements/graphics/prayer/ask) // Prevent toggling mode for fixed-mode tabs (whiteboard/notes/alpha/announcements/graphics/maps/browser/prayer/ask)
if (tab.mode === 'whiteboard' || tab.mode === 'notes' || tab.mode === 'alpha' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask' || tab.ppsync) { if (tab.mode === 'whiteboard' || tab.mode === 'notes' || tab.mode === 'alpha' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'browser' || tab.mode === 'prayer' || tab.mode === 'ask' || tab.ppsync) {
console.log('[MODE SWITCH] Fixed-mode tab; ignoring toggle for tab:', tabId); console.log('[MODE SWITCH] Fixed-mode tab; ignoring toggle for tab:', tabId);
return; return;
} }
@@ -8982,8 +9065,8 @@ export default class BibleApp {
} }
}); });
} catch (_) {} } catch (_) {}
} else if (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps') { } else if (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'browser') {
// Announcements/Graphics/Maps: tools otherwise unchanged (highlighter reset above). // Announcements/Graphics/Maps/Browser: tools otherwise unchanged (highlighter reset above).
try { try {
runAutomaticOperation(() => { runAutomaticOperation(() => {
if (typeof updateToolUI === 'function') { if (typeof updateToolUI === 'function') {
@@ -9279,7 +9362,7 @@ export default class BibleApp {
const key = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|'); const key = [tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation].join('|');
const bt = tab._panelEl.querySelector(`#bibleText${tab.id}`); const bt = tab._panelEl.querySelector(`#bibleText${tab.id}`);
if (tab._panelKey === key && bt && bt.textContent && bt.textContent.trim().length > 0) { if (tab._panelKey === key && bt && bt.textContent && bt.textContent.trim().length > 0) {
tabContent.innerHTML = ''; clearTabContentKeepingBrowser(tabContent);
tab._panelEl.classList.remove('tab-fade-enter', 'tab-fade-enter-active'); tab._panelEl.classList.remove('tab-fade-enter', 'tab-fade-enter-active');
tab._panelEl.classList.add('active'); tab._panelEl.classList.add('active');
tabContent.appendChild(tab._panelEl); tabContent.appendChild(tab._panelEl);
@@ -9323,7 +9406,7 @@ export default class BibleApp {
} }
} catch (_) {} } catch (_) {}
} }
tabContent.innerHTML = ''; clearTabContentKeepingBrowser(tabContent);
const panelElement = document.createElement('div'); const panelElement = document.createElement('div');
// Start hidden to allow a quick fade-in when switching tabs // Start hidden to allow a quick fade-in when switching tabs
panelElement.className = 'tab-panel active tab-fade-enter'; panelElement.className = 'tab-panel active tab-fade-enter';
@@ -9862,6 +9945,13 @@ export default class BibleApp {
initializeCarousel(); initializeCarousel();
try { ensureContentResizeObserver(tab.id); } catch (_) {} try { ensureContentResizeObserver(tab.id); } catch (_) {}
}, 50); // Small delay to ensure DOM is ready }, 50); // Small delay to ensure DOM is ready
} else if (tab.mode === 'browser') {
// Browser: the page is NOT in this panel. It lives in browser-tab.js's persistent layer over the
// content area, because rebuilding it here on every switch would reload it. The panel is just a
// black reading area, so everything that looks up readingArea<id> for the active tab finds one.
panelElement.innerHTML = `
<div class="reading-area browser-reading-area" id="readingArea${tab.id}" style="display:block;background:#000;overflow:hidden;padding:0;margin:0;"></div>`;
setTimeout(() => { try { if (window.__browserTab) window.__browserTab.sync(); } catch (_) {} }, 0);
} else if (tab.mode === 'graphics' || tab.mode === 'maps') { } else if (tab.mode === 'graphics' || tab.mode === 'maps') {
// Graphics & Maps: identical image-first swipeable carousel; only the data source differs // 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. // (/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 = { const COMPANION_TAB_KIND = {
bible: 'Bible', whiteboard: 'Whiteboard', notes: 'Notes', media: 'Media', alpha: 'Alpha', 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 companionActiveTabSignature = null;
let companionActiveTabTimer = 0; 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 // 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. // 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(), slug: tab.ppsync ? 'sync' : String(tab.mode || 'tab').toLowerCase(),
passage: (tab.book && tab.chapter) ? `${tab.book} ${tab.chapter}` : null, // The Browser tab has no passage; its search term (or page title) answers "what is up there" instead.
translation: tab.translation || null 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); const sig = JSON.stringify(payload);
if (sig === companionActiveTabSignature) return; if (sig === companionActiveTabSignature) return;
@@ -16950,6 +17041,7 @@ document.addEventListener('DOMContentLoaded', () => {
{ mode: 'announcements', label: 'Announcements', icon: 'A' }, { mode: 'announcements', label: 'Announcements', icon: 'A' },
{ mode: 'graphics', label: 'Graphics', icon: 'G' }, { mode: 'graphics', label: 'Graphics', icon: 'G' },
{ mode: 'maps', label: 'Maps', icon: 'M' }, { mode: 'maps', label: 'Maps', icon: 'M' },
{ mode: 'browser', label: 'Browser', icon: '\u{1F310}' },
{ mode: 'whiteboard', label: 'Whiteboard', icon: 'WB' }, { mode: 'whiteboard', label: 'Whiteboard', icon: 'WB' },
{ mode: 'notes', label: 'Notes', icon: 'N' }, { mode: 'notes', label: 'Notes', icon: 'N' },
{ mode: 'prayer', label: 'Prayer', icon: 'P' }, { mode: 'prayer', label: 'Prayer', icon: 'P' },
@@ -16973,6 +17065,7 @@ document.addEventListener('DOMContentLoaded', () => {
announcements: true, announcements: true,
graphics: true, graphics: true,
maps: true, maps: true,
browser: true,
whiteboard: true, whiteboard: true,
notes: true, notes: true,
prayer: true, prayer: true,
@@ -17009,6 +17102,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (mode === 'announcements' && typeof app.createAnnouncementsTab === 'function') app.createAnnouncementsTab(); if (mode === 'announcements' && typeof app.createAnnouncementsTab === 'function') app.createAnnouncementsTab();
else if (mode === 'graphics' && typeof app.createGraphicsTab === 'function') app.createGraphicsTab(); else if (mode === 'graphics' && typeof app.createGraphicsTab === 'function') app.createGraphicsTab();
else if (mode === 'maps' && typeof app.createMapsTab === 'function') app.createMapsTab(); 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 === 'whiteboard' && typeof app.createWhiteboardTab === 'function') app.createWhiteboardTab();
else if (mode === 'notes' && typeof app.createNotesTab === 'function') app.createNotesTab(); else if (mode === 'notes' && typeof app.createNotesTab === 'function') app.createNotesTab();
else if (mode === 'prayer' && typeof app.createPrayerTab === 'function') app.createPrayerTab(); else if (mode === 'prayer' && typeof app.createPrayerTab === 'function') app.createPrayerTab();
@@ -17364,6 +17458,8 @@ try {
*/ */
try { try {
const tabId = mirrorTabId(); 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; const readingArea = tabId ? document.getElementById(`readingArea${tabId}`) : null;
if (!readingArea) break; if (!readingArea) break;
if (payload.t !== undefined) { if (payload.t !== undefined) {
@@ -17439,18 +17535,23 @@ try {
// The phone remote's reading-navigation buttons. These call exactly what the on-screen // 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. // chapter-header buttons call, so scrolling from the phone behaves identically.
case 'scroll_top': { 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 (_) {} try { if (window.__headerNav) window.__headerNav.top(window.bibleApp.activeTabId); } catch (_) {}
break; break;
} }
case 'scroll_bottom': { 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 (_) {} try { if (window.__headerNav) window.__headerNav.bottom(window.bibleApp.activeTabId); } catch (_) {}
break; break;
} }
case 'scroll_up_5': { 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 (_) {} try { if (window.__headerNav) window.__headerNav.by(-5, window.bibleApp.activeTabId); } catch (_) {}
break; break;
} }
case 'scroll_down_5': { 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 (_) {} try { if (window.__headerNav) window.__headerNav.by(5, window.bibleApp.activeTabId); } catch (_) {}
break; break;
} }
@@ -17658,6 +17759,11 @@ try {
localStorage.setItem(key, str); localStorage.setItem(key, str);
switch (key) { switch (key) {
case 'browserTabUrl':
case 'browserTabZoom':
case 'browserTabScriptureTaps':
try { if (window.__browserTab) window.__browserTab.applySettings(key); } catch (_) {}
break;
case 'tabVisibility': case 'tabVisibility':
// Re-render the tab bar; renderTabs() also moves off a tab it just hid. // 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(); if (window.bibleApp && typeof window.bibleApp.renderTabs === 'function') window.bibleApp.renderTabs();
@@ -17780,6 +17886,24 @@ try {
} catch (_) {} } catch (_) {}
break; 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': { case 'switchToMode': {
try { try {
const app = window.bibleApp; const app = window.bibleApp;
@@ -17841,6 +17965,9 @@ try {
const x = px * window.innerWidth, y = py * window.innerHeight; const x = px * window.innerWidth, y = py * window.innerHeight;
const ov = document.querySelector('.splash-overlay'); const ov = document.querySelector('.splash-overlay');
if (ov && !ov.classList.contains('hidden')) { try { window.hideSplash(); } catch (_) {} break; } if (ov && !ov.classList.contains('hidden')) { try { window.hideSplash(); } catch (_) {} break; }
// Over the Browser tab's page, elementFromPoint finds only the <webview>; 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); const el = document.elementFromPoint(x, y);
if (!el) break; if (!el) break;
const opts = { clientX: x, clientY: y, bubbles: true, cancelable: true, view: window }; 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 // 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); 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); console.log('[DEBUG] Skipping annotation canvas creation for', tab.mode, 'tab:', tabId);
return null; return null;
} }
@@ -23516,7 +23643,7 @@ function setupAnnotationForActiveTab() {
console.log('[DEBUG] *** setupAnnotationForActiveTab called, tabId:', tabId, 'drawing state:', drawing); console.log('[DEBUG] *** setupAnnotationForActiveTab called, tabId:', tabId, 'drawing state:', drawing);
if (!tabId) return; if (!tabId) return;
const tab = getTabById(tabId); 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'); console.log('[DEBUG] Skipping annotation setup for', tab.mode, 'tab');
return; return;
} }
@@ -23994,7 +24121,7 @@ function startDrawing(e) {
const tabId = getActiveTabId(); const tabId = getActiveTabId();
const activeTab = getTabById(tabId); 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'); console.log('[DEBUG] Drawing blocked on', activeTab.mode, 'tab');
return; return;
} }
@@ -24711,6 +24838,7 @@ function getPassageKeyForTabId(tabId) {
if (tab.mode === 'announcements') return `announcements-annotations-${tabId}`; if (tab.mode === 'announcements') return `announcements-annotations-${tabId}`;
if (tab.mode === 'graphics') return `graphics-annotations-${tabId}`; if (tab.mode === 'graphics') return `graphics-annotations-${tabId}`;
if (tab.mode === 'maps') return `maps-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}`; if (tab.mode === 'notes') return `notes-annotations-${tabId}`;
return getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync); return getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync);
} }
+388
View File
@@ -0,0 +1,388 @@
/*
* browser-guest-preload.cjs — runs inside the web page that the Browser tab shows (the Electron
* <webview> 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 <webview> 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 ("<b>John</b> 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 <style> text.
*/
let domVer = 0, domTimer = 0, domDirty = true, observer = null;
function setWantDom(on) {
if (on === wantDom) return;
wantDom = on;
if (on) { domDirty = true; publishDom(true); }
}
function ours(n) {
const el = n && (n.nodeType === 1 ? n : n.parentElement);
return !!(el && el.closest && el.closest('[' + OVERLAY_ATTR + ']'));
}
function startObserver() {
if (observer || !document.documentElement) return;
observer = new MutationObserver((muts) => {
for (const m of muts) {
if (m.type === 'attributes' && m.attributeName === 'inputmode') continue; // our own, see quietOsKeyboard
if (ours(m.target)) continue;
if (m.type === 'childList' && [...m.addedNodes, ...m.removedNodes].length && [...m.addedNodes, ...m.removedNodes].every(ours)) continue;
domDirty = true;
reportScrollSoon(); // new content moves the bottom of the page
if (wantDom && !domTimer) domTimer = setTimeout(() => { domTimer = 0; publishDom(false); }, 350);
return;
}
});
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true });
}
function fields(root) { return root.querySelectorAll('input, textarea, select'); }
function fieldValue(el) {
if (el.tagName === 'SELECT') return el.selectedIndex;
if (el.type === 'checkbox' || el.type === 'radio') return !!el.checked;
if (el.type === 'password' || el.type === 'file') return ''; // never put a password on the program feed
return el.value;
}
function serialize() {
const live = document.documentElement;
const clone = live.cloneNode(true);
const ls = live.querySelectorAll('style'), cs = clone.querySelectorAll('style');
for (let i = 0; i < ls.length && i < cs.length; i++) {
try {
const rules = ls[i].sheet && ls[i].sheet.cssRules;
if (rules && rules.length) { let t = ''; for (const r of rules) t += r.cssText + '\n'; cs[i].textContent = t; }
} catch (_) {}
}
try {
const adopted = document.adoptedStyleSheets || [];
if (adopted.length) {
let t = '';
adopted.forEach((sh) => { for (const r of sh.cssRules) t += r.cssText + '\n'; });
const st = document.createElement('style');
st.textContent = t;
(clone.querySelector('head') || clone).appendChild(st);
}
} catch (_) {}
const lf = fields(live), cf = fields(clone);
for (let i = 0; i < lf.length && i < cf.length; i++) {
const a = lf[i], b = cf[i], v = fieldValue(a);
try {
if (a.tagName === 'TEXTAREA') b.textContent = v;
else if (a.tagName === 'SELECT') Array.from(b.options).forEach((o, k) => { if (k === v) o.setAttribute('selected', ''); else o.removeAttribute('selected'); });
else if (a.type === 'checkbox' || a.type === 'radio') { if (v) b.setAttribute('checked', ''); else b.removeAttribute('checked'); }
else b.setAttribute('value', v);
} catch (_) {}
}
clone.querySelectorAll('script, noscript, base, meta[http-equiv], [' + OVERLAY_ATTR + ']').forEach((n) => n.remove());
let head = clone.querySelector('head');
if (!head) { head = document.createElement('head'); clone.insertBefore(head, clone.firstChild); }
const base = document.createElement('base');
base.setAttribute('href', location.href);
head.insertBefore(base, head.firstChild);
return '<!DOCTYPE html>' + clone.outerHTML;
}
function sendInputs() {
try { send('bibleapp:inputs', { values: Array.from(fields(document), fieldValue) }); } catch (_) {}
}
function publishDom(force) {
if (!wantDom || (!domDirty && !force) || !document.documentElement) return;
domDirty = false;
let html;
try { html = serialize(); } catch (_) { return; }
domVer++;
send('bibleapp:dom', { ver: domVer, html: html });
sendInputs();
}
// Typing changes a property, not the DOM, so it never wakes the observer: field values travel on their own,
// a few bytes per keystroke instead of the whole page.
let inputsTimer = 0;
function inputsSoon() { if (wantDom && !inputsTimer) inputsTimer = setTimeout(() => { inputsTimer = 0; sendInputs(); }, 60); }
document.addEventListener('input', inputsSoon, true);
document.addEventListener('change', inputsSoon, true);
window.addEventListener('DOMContentLoaded', () => {
startObserver();
reportScroll(true);
send('bibleapp:ready', { url: location.href, title: document.title });
});
+792
View File
@@ -0,0 +1,792 @@
/*
* browser-tab.js — the Browser tab: a live web page, full-bleed in the content area, with no browser
* chrome at all (no address bar, bookmarks or scrollbars). Isolated + additive, like quick-dock.js.
*
* WHAT RUNS WHERE. One file, three renderers, picked once at load:
*
* stage the Electron window on the TV. Each Browser tab owns an Electron <webview> — the same
* Chromium engine as Chrome, in its own sandboxed process and its own persistent session
* (persist:browser-tab), so the site's settings and any sign-in survive restarts.
* browser-guest-preload.cjs runs inside the page and talks to this file.
* follower the booth screen (?follower=1). It never loads the site: it shows the stage's copy of the
* page's DOM (scripts stripped) and follows its scroll and form fields. Loading the site
* itself would re-run the search and could show the program feed different results.
* live a plain browser — the admin console's offstage copy, or `npm start` in a desktop browser.
* There is no <webview> there, so the page is a sandboxed iframe of the same address. It
* shows the page; the on-screen keyboard and scripture taps need the stage.
*
* WHY THE PAGES LIVE IN THEIR OWN LAYER. Every tab switch rebuilds .tab-content, and moving or
* re-creating a <webview> reloads its page — the search, the scroll position and anything typed would
* all be lost each time pastor looked at another tab. So the pages live in #browserTabHost, one
* persistent layer inside .tab-content that app.js never clears (clearTabContentKeepingBrowser), laid
* exactly over the content area. Switching tabs only shows or hides it, which is also what keeps a page
* "running in the background": it loads at startup and is ready the first time the tab is opened.
*
* WHAT THE PAGE CANNOT DO ON ITS OWN, and is wired here:
* - the app's on-screen keyboard (the Prayer tab's, reused) types into the focused field of the page;
* - a tapped scripture reference opens in a new Bible tab, in the page's translation when the app has it;
* - touches inside the page reset the idle splash, and five fingers still blank the screen;
* - the phone remote's scroll, tap and page buttons, and OSC / admin reload and home, reach the page;
* - the booth follower gets the page, and the phone mirror gets its scroll position.
*/
(function () {
'use strict';
var KEY_URL = 'browserTabUrl';
var KEY_ZOOM = 'browserTabZoom';
var KEY_TAPS = 'browserTabScriptureTaps';
var SESSION_KEY = 'browserTabSession';
var DEFAULT_URL = 'https://it-is-written-waters-church.vercel.app/';
var PARTITION = 'persist:browser-tab';
var RETRY_MS = 10000;
/*
* The page itself is the only thing on screen: no scrollbars, no blue tap flash, no text selection from
* a resting finger, and no rubber-band pull that would drag the whole page (or, sideways, swipe back a
* page). Inputs keep their selection so typing still behaves. Applied to the live page with insertCSS
* and to the booth's copy as a <style>, so both screens look the same.
*/
var CLEAN_CSS =
'html,body{scrollbar-width:none!important;overscroll-behavior:none!important;}' +
'::-webkit-scrollbar{display:none!important;width:0!important;height:0!important;}' +
'*{-webkit-tap-highlight-color:transparent!important;}' +
'body *{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;}' +
'input,textarea,[contenteditable],[contenteditable] *{-webkit-user-select:text!important;user-select:text!important;}';
function ls(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
function log() { try { console.log.apply(console, ['[BROWSER TAB]'].concat([].slice.call(arguments))); } catch (_) {} }
// ------------------------------------------------------------------ role
function detectRole() {
var q = '';
try { q = window.location.search || ''; } catch (_) {}
if (window.__followerMode || /(^|[?&])follower=1(&|$)/.test(q)) return 'follower';
if (window.__adminOffstage || /(^|[?&])admin=1(&|$)/.test(q)) return 'live';
/*
* The stage is the Electron window — the only one with the preload bridge (the same test
* follower-publish.js uses). Not "is <webview> defined": Electron registers that element after this
* script has already run, so asking here would wrongly put the stage on the plain-browser path.
*/
try { if (window.electron && window.electron.ipcRenderer) return 'stage'; } catch (_) {}
return 'live';
}
function webviewReady() { try { return !!(window.customElements && window.customElements.get('webview')); } catch (_) { return false; } }
var ROLE = detectRole();
// ------------------------------------------------------------------ settings
function startUrl() {
var u = String(ls(KEY_URL) || '').trim();
return /^https?:\/\//i.test(u) ? u : DEFAULT_URL;
}
function tapsOn() { return ls(KEY_TAPS) !== '0'; }
/*
* Page size. The stage is a 4K portrait TV (1080 CSS px across); at 100% a site meant for a laptop is a
* narrow column of small text down the middle. "Auto" lays the page out as if ~540 px wide — a tablet
* — and scales that up to fill the width, which is how a site looks best on a tall, narrow screen.
* A percentage in Settings › Browser Tab overrides it.
*/
function zoomFor(widthPx) {
var z = String(ls(KEY_ZOOM) || 'auto');
if (z !== 'auto') {
var n = parseFloat(z);
if (isFinite(n) && n >= 25 && n <= 500) return n / 100;
}
var w = widthPx || window.innerWidth || 1080;
return Math.max(1, Math.min(2.5, Math.round((w / 540) * 20) / 20));
}
/*
* Where each page was, kept for this window session only: a trip to Settings navigates this window away
* and back, and coming back to the start page would lose the search pastor typed. A fresh app launch
* (or Reset, or changing the start page) begins at the start page again.
*/
function readSession() {
try {
var s = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null');
if (s && s.start === startUrl() && Array.isArray(s.tabs)) return s;
} catch (_) {}
return { start: startUrl(), tabs: [] };
}
var saveTimer = 0;
function saveSessionSoon() {
if (ROLE !== 'stage' || saveTimer) return;
saveTimer = setTimeout(function () {
saveTimer = 0;
try {
var out = { start: startUrl(), tabs: browserTabs().map(function (t) {
var v = views[String(t.id)];
return v ? { url: v.url || null, y: v.scrollY || 0 } : null;
}) };
sessionStorage.setItem(SESSION_KEY, JSON.stringify(out));
} catch (_) {}
}, 400);
}
function resetSession() { restore = null; try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {} }
/*
* Restoring is for the pages that were open when this window loaded (coming back from Settings). It is
* read once, handed out as those tabs get their pages, then dropped: a Browser tab added later from the
* + menu opens the start page rather than inheriting a closed tab's search.
*/
var restore = (ROLE === 'stage') ? readSession().tabs.slice() : null;
// ------------------------------------------------------------------ app access
function app() { return window.bibleApp || null; }
function browserTabs() {
var a = app();
return (a && Array.isArray(a.tabs)) ? a.tabs.filter(function (t) { return t.mode === 'browser'; }) : [];
}
function tabById(id) {
var a = app();
if (!a || !a.tabs) return null;
for (var i = 0; i < a.tabs.length; i++) if (String(a.tabs[i].id) === String(id)) return a.tabs[i];
return null;
}
function activeTab() { var a = app(); return a ? tabById(a.activeTabId) : null; }
function activeView() {
var t = activeTab();
return (t && t.mode === 'browser') ? (views[String(t.id)] || null) : null;
}
// ------------------------------------------------------------------ layer
var styled = false;
function styleOnce() {
if (styled) return;
styled = true;
var st = document.createElement('style');
st.id = 'browserTabStyle';
st.textContent = [
// The layer covers exactly the content area. Measured on every tab type: making .tab-content a
// positioning context moves nothing that is already there.
'.tab-content{position:relative;}',
'#browserTabHost{position:absolute;inset:0;z-index:2;overflow:hidden;background:#000;visibility:hidden;pointer-events:none;}',
'#browserTabHost.bt-on{visibility:visible;pointer-events:auto;}',
/* Hidden views keep their size: a zero-sized page would reflow and lose its place. */
'#browserTabHost .bt-view{position:absolute;left:0;top:0;width:100%;height:100%;border:0;margin:0;padding:0;background:#000;visibility:hidden;}',
'#browserTabHost .bt-view.bt-active{visibility:visible;}',
/* A page paints white before its own background arrives; on a black stage that is a flash. */
'#browserTabHost webview.bt-view{opacity:0;transition:opacity .2s ease;}',
'#browserTabHost webview.bt-view.bt-painted{opacity:1;}',
'#browserTabHost iframe.bt-view{transform-origin:0 0;}',
'#browserTabHost .bt-status{position:absolute;left:0;right:0;top:38%;text-align:center;color:#8a8a8a;' +
'font:500 22px/1.5 Georgia,serif;padding:0 32px;pointer-events:none;visibility:hidden;}',
'#browserTabHost .bt-status.bt-show{visibility:visible;}',
'#browserKeyboard{z-index:20001;}'
].join('\n');
document.head.appendChild(st);
}
function host() {
var tc = document.querySelector('.tab-content');
if (!tc) return null;
styleOnce();
var h = document.getElementById('browserTabHost');
if (!h) {
h = document.createElement('div');
h.id = 'browserTabHost';
h.setAttribute('aria-label', 'Browser tab');
tc.insertBefore(h, tc.firstChild);
}
return h;
}
// ------------------------------------------------------------------ views
var views = Object.create(null); // String(tabId) -> view
function statusEl(v) {
if (v.status) return v.status;
var s = document.createElement('div');
s.className = 'bt-status';
v.status = s;
var h = host();
if (h) h.appendChild(s);
return s;
}
function setStatus(v, text) {
var s = statusEl(v);
s.textContent = text || '';
s.classList.toggle('bt-show', !!text && isShown(v));
}
function isShown(v) { var t = activeTab(); return !!(t && String(t.id) === v.id); }
function create(tab, ordinal) {
var h = host();
if (!h) return null;
// Moments after load <webview> may not be registered yet; sync() comes back for this tab shortly.
if (ROLE === 'stage' && !webviewReady()) { setTimeout(sync, 150); return null; }
var id = String(tab.id);
var sess = restore ? restore[ordinal] : null;
if (restore) restore[ordinal] = null;
var url = (ROLE === 'stage' && sess && sess.url) ? sess.url : startUrl();
var v = {
id: id, kind: null, el: null, url: url, title: '', zoom: 1,
scrollY: 0, scrollMax: 0, vh: 0, pendingScroll: (ROLE === 'stage' && sess && sess.y) ? sess.y : null,
domVer: 0, domHtml: null, inputs: null, ready: false, retry: 0, status: null
};
views[id] = v;
if (ROLE === 'stage') makeWebview(v);
else if (ROLE === 'follower') makeSnapshotFrames(v);
else makeLiveFrame(v);
setLabel(tab, v);
log('page ready to load for tab', id, '→', ROLE === 'follower' ? '(follows the stage)' : url);
return v;
}
function destroy(id) {
var v = views[id];
if (!v) return;
delete views[id];
try { if (v.retry) clearTimeout(v.retry); } catch (_) {}
[v.el, v.frames && v.frames[0], v.frames && v.frames[1], v.status].forEach(function (n) {
try { if (n && n.parentNode) n.parentNode.removeChild(n); } catch (_) {}
});
}
// Keep one view per Browser tab: new tabs (the + menu, Reset) get a page, closed tabs give theirs up.
function reconcile() {
var tabs = browserTabs();
var keep = Object.create(null);
tabs.forEach(function (t, i) {
var id = String(t.id);
keep[id] = true;
if (!views[id]) create(t, i);
});
Object.keys(views).forEach(function (id) { if (!keep[id]) destroy(id); });
// Every tab that was open at load now has its page: nothing left to restore.
if (restore && tabs.length && tabs.every(function (t) { return !!views[String(t.id)]; })) restore = null;
}
/*
* What the tab is about, for the OSC information line ("4/10 Browser | Hope | LIVE") and the phone
* remote: the site's search term when the address carries one, else the page title.
*/
function setLabel(tab, v) {
if (!tab) return;
var label = '';
try {
var u = new URL(v.url);
label = u.searchParams.get('q') || u.searchParams.get('query') || u.searchParams.get('search') || '';
} catch (_) {}
if (!label) label = v.title || '';
label = String(label).replace(/\s+/g, ' ').trim().slice(0, 40);
if (tab.browserLabel === label) return;
tab.browserLabel = label;
tab.mediaTitle = label || 'Browser';
try { if (typeof window.scheduleCompanionActiveTabFeedback === 'function') window.scheduleCompanionActiveTabFeedback(); } catch (_) {}
}
// ---- stage: a real page in a <webview>
var scannerSrc = null;
function loadScanner() {
if (scannerSrc !== null) return Promise.resolve(scannerSrc);
// The app's own scanner files, handed to the page's preload (see browser-guest-preload.cjs for why).
return Promise.all(['book-aliases.js', 'scripture-scan.js'].map(function (f) {
return fetch(f).then(function (r) { return r.ok ? r.text() : ''; }).catch(function () { return ''; });
})).then(function (parts) {
scannerSrc = (parts[0] && parts[1]) ? parts.join('\n;\n') : '';
return scannerSrc;
});
}
function makeWebview(v) {
var wv = document.createElement('webview');
wv.className = 'bt-view';
wv.setAttribute('partition', PARTITION);
// main.cjs forces the real preferences in will-attach-webview; these only state the intent.
wv.setAttribute('webpreferences', 'contextIsolation=yes,sandbox=yes,backgroundThrottling=no');
// Lets main see window.open / target=_blank so it can open them in this same view (setWindowOpenHandler),
// instead of the request vanishing silently or a new window landing on the stage.
wv.setAttribute('allowpopups', '');
wv.setAttribute('src', v.url);
v.kind = 'webview';
v.el = wv;
wv.addEventListener('dom-ready', function () {
v.ready = true;
applyZoom(v);
try { wv.insertCSS(CLEAN_CSS); } catch (_) {}
loadScanner().then(function (src) {
try { wv.send('bibleapp:init', { scanner: src, taps: tapsOn(), wantDom: wantDom }); } catch (_) {}
});
});
wv.addEventListener('did-finish-load', function () {
setStatus(v, '');
wv.classList.add('bt-painted');
if (v.pendingScroll) {
// The page builds its content after load (this site fetches its results), so restoring where it
// was has to wait until there is something to scroll to.
var y = v.pendingScroll; v.pendingScroll = null;
[300, 1200, 3000, 6000].forEach(function (ms) {
setTimeout(function () { if (v.scrollY < y - 4) scrollView(v, { to: y }); }, ms);
});
}
});
wv.addEventListener('did-fail-load', function (e) {
// -3 is "aborted": a navigation superseded by another one. Not a failure anyone needs to see.
if (!e || !e.isMainFrame || e.errorCode === -3) return;
var hostName = '';
try { hostName = new URL(e.validatedURL || v.url).host; } catch (_) {}
setStatus(v, 'Can’t reach ' + (hostName || 'the page') + ' right now. Trying again…');
// The TV often boots before its network is up; keep trying instead of leaving a blank tab.
if (v.retry) clearTimeout(v.retry);
v.retry = setTimeout(function () { v.retry = 0; try { wv.loadURL(v.url); } catch (_) {} }, RETRY_MS);
});
wv.addEventListener('render-process-gone', function () {
setStatus(v, 'The page stopped. Reloading…');
setTimeout(function () { try { wv.reload(); } catch (_) {} }, 1500);
});
var onNav = function (e) {
if (e && e.isMainFrame === false) return;
if (e && e.url && /^https?:/i.test(e.url)) v.url = e.url;
setLabel(tabById(v.id), v);
saveSessionSoon();
};
wv.addEventListener('did-navigate', onNav);
wv.addEventListener('did-navigate-in-page', onNav);
wv.addEventListener('page-title-updated', function (e) { v.title = (e && e.title) || ''; setLabel(tabById(v.id), v); });
wv.addEventListener('ipc-message', function (e) { onGuestMessage(v, e.channel, e.args && e.args[0]); });
host().appendChild(wv);
}
function onGuestMessage(v, channel, d) {
switch (channel) {
case 'bibleapp:scroll':
if (!d) return;
v.scrollY = Number(d.y) || 0;
v.scrollMax = Number(d.max) || 0;
v.vh = Number(d.vh) || 0;
saveSessionSoon();
return;
case 'bibleapp:editable':
// Only the page on screen may raise or drop the keyboard; a background page focusing its own
// search box as it loads must not.
if (!d || !isShown(v)) return;
if (d.on && d.tap) showKeyboard();
else if (!d.on) hideKeyboard();
return;
case 'bibleapp:activity':
// Input inside a webview never reaches the app's own listeners, so the idle splash would otherwise
// come down over the page while pastor is scrolling it.
try { if (typeof window.__resetIdleTimer === 'function') window.__resetIdleTimer(); } catch (_) {}
return;
case 'bibleapp:five-finger':
try {
if (ls('fiveFingerSplash') !== '0' && typeof window.showSplash === 'function') window.showSplash('five-finger touch (browser tab)');
} catch (_) {}
return;
case 'bibleapp:scripture':
if (!d || !d.book || !d.chapter || !isShown(v)) return;
log('opening', d.canonical, d.translation ? '(' + d.translation + ')' : '');
try {
if (typeof window.openPassageInNewTab === 'function') {
window.openPassageInNewTab(d.book, d.chapter, d.verse || 1, d.verseEnd || null, d.translation || null);
}
} catch (_) {}
return;
case 'bibleapp:dom':
if (!d || typeof d.html !== 'string') return;
// Our own counter, never the page's: the page's restarts at 1 with every load, and so would ours
// with every app restart — a booth that had already shown "1" would then ignore the new page.
v.domVer = Math.max(Date.now(), (v.domVer || 0) + 1);
v.domHtml = d.html;
return;
case 'bibleapp:inputs':
if (d && Array.isArray(d.values)) v.inputs = d.values;
return;
case 'bibleapp:ready':
if (d && d.url) v.url = d.url;
if (d && d.title) v.title = d.title;
setLabel(tabById(v.id), v);
return;
}
}
function applyZoom(v) {
var h = host();
var z = zoomFor(h ? h.clientWidth : 0);
if (v.kind === 'webview') {
if (!v.ready) return;
try { v.el.setZoomFactor(z); v.zoom = z; } catch (_) {}
} else if (v.kind === 'live') {
sizeFrame(v.el, z);
v.zoom = z;
}
}
// An iframe has no zoom of its own: lay it out at 1/zoom of the area and scale it back up. Text wraps
// exactly as in the stage's zoomed webview, so the booth's copy scrolls to the same lines.
function sizeFrame(f, z) {
if (!f) return;
z = (isFinite(z) && z > 0) ? z : 1;
f.style.width = (100 / z) + '%';
f.style.height = (100 / z) + '%';
f.style.transform = z === 1 ? '' : 'scale(' + z + ')';
}
function scrollView(v, cmd) {
if (!v) return false;
if (v.kind === 'webview') {
if (!v.ready) return false;
try { v.el.send('bibleapp:scroll', cmd); } catch (_) { return false; }
return true;
}
// live / follower frames: same-origin only (the follower's copy is); a cross-origin live page scrolls by touch.
var f = v.kind === 'snapshot' ? v.frames[v.front] : v.el;
try {
var w = f.contentWindow, d = f.contentDocument;
var se = d.scrollingElement || d.documentElement;
var max = Math.max(0, se.scrollHeight - se.clientHeight);
var y = cmd.edge === 'top' ? 0 : cmd.edge === 'bottom' ? max
: typeof cmd.to === 'number' ? cmd.to
: typeof cmd.by === 'number' ? se.scrollTop + cmd.by
: typeof cmd.pages === 'number' ? se.scrollTop + cmd.pages * w.innerHeight * 0.85 : null;
if (y === null) return false;
w.scrollTo({ top: Math.max(0, Math.min(max, y)), behavior: cmd.smooth ? 'smooth' : 'instant' });
return true;
} catch (_) { return false; }
}
function go(v, url) {
if (!v || !/^https?:\/\//i.test(url || '')) return;
v.url = url;
v.pendingScroll = null;
if (v.kind === 'webview') { try { v.el.loadURL(url); } catch (_) { v.el.setAttribute('src', url); } }
else if (v.kind === 'live') v.el.setAttribute('src', url);
setLabel(tabById(v.id), v);
saveSessionSoon();
}
function reload(v) {
if (!v) return;
if (v.kind === 'webview') { try { v.el.reload(); } catch (_) {} }
else if (v.kind === 'live') { var s = v.el.getAttribute('src'); v.el.setAttribute('src', 'about:blank'); setTimeout(function () { v.el.setAttribute('src', s); }, 30); }
}
// ---- live: a plain browser (admin offstage copy, desktop dev)
function makeLiveFrame(v) {
var f = document.createElement('iframe');
f.className = 'bt-view';
// Scripts and forms work; the page may not navigate the admin's own window away (frame-busting).
f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
f.setAttribute('referrerpolicy', 'no-referrer');
f.setAttribute('src', v.url);
v.kind = 'live';
v.el = f;
host().appendChild(f);
applyZoom(v);
}
// ---- follower: the stage's page, as DOM
/*
* Two frames, swapped once the incoming copy has loaded and been scrolled into place, so a change on the
* stage never shows the program feed a blank frame or a jump to the top in between. allow-same-origin
* WITHOUT allow-scripts: nothing in the copy can run, and this file can still scroll it and fill its fields.
*/
function makeSnapshotFrames(v) {
var mk = function () {
var f = document.createElement('iframe');
f.className = 'bt-view';
f.setAttribute('sandbox', 'allow-same-origin');
f.setAttribute('tabindex', '-1');
f.setAttribute('aria-hidden', 'true');
host().appendChild(f);
return f;
};
v.kind = 'snapshot';
v.frames = [mk(), mk()];
v.front = 0;
v.el = v.frames[0];
v.loadingVer = 0;
}
function followerHtml(html) {
var extra = '<style data-bibleapp>' + CLEAN_CSS + 'html{scroll-behavior:auto!important;}</style>';
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, extra + '</head>') : extra + html;
}
function applyInputsTo(frame, values) {
if (!frame || !Array.isArray(values)) return;
try {
var list = frame.contentDocument.querySelectorAll('input, textarea, select');
for (var i = 0; i < list.length && i < values.length; i++) {
var el = list[i], val = values[i];
if (el.tagName === 'SELECT') { if (typeof val === 'number') el.selectedIndex = val; }
else if (el.type === 'checkbox' || el.type === 'radio') el.checked = !!val;
else if (el.value !== String(val == null ? '' : val)) el.value = String(val == null ? '' : val);
}
} catch (_) {}
}
function applyScrollTo(frame, y) {
try { frame.contentWindow.scrollTo({ top: y, behavior: 'instant' }); return true; } catch (_) { return false; }
}
function showFront(v) {
v.frames.forEach(function (f, i) { f.classList.toggle('bt-active', i === v.front && isShown(v)); });
v.el = v.frames[v.front];
}
function followerApply(v, b) {
if (!v || v.kind !== 'snapshot' || !b) return;
if (typeof b.zoom === 'number' && b.zoom > 0 && b.zoom !== v.zoom) {
v.zoom = b.zoom;
v.frames.forEach(function (f) { sizeFrame(f, b.zoom); });
}
if (typeof b.url === 'string') v.url = b.url;
if (Array.isArray(b.inputs)) v.inputs = b.inputs;
if (typeof b.scrollY === 'number') v.scrollY = b.scrollY;
if (typeof b.html === 'string' && b.domVer && b.domVer !== v.domVer && b.domVer !== v.loadingVer) {
var backIdx = 1 - v.front, back = v.frames[backIdx], ver = b.domVer;
v.loadingVer = ver;
back.onload = function () {
if (v.loadingVer !== ver) return; // a newer copy overtook this one
applyInputsTo(back, v.inputs);
applyScrollTo(back, v.scrollY);
v.front = backIdx;
v.domVer = ver;
v.loadingVer = 0;
showFront(v);
// A late image can still grow the page; settle the scroll once more after it has had a moment.
setTimeout(function () { applyScrollTo(v.frames[v.front], v.scrollY); }, 250);
};
back.srcdoc = followerHtml(b.html);
}
var front = v.frames[v.front];
if (Array.isArray(b.inputs)) applyInputsTo(front, b.inputs);
if (typeof b.scrollY === 'number') applyScrollTo(front, b.scrollY);
}
// ------------------------------------------------------------------ show / hide
var lastActiveId = null;
function sync() {
if (!app()) return;
reconcile();
var h = host();
if (!h) return;
var t = activeTab();
var on = !!(t && t.mode === 'browser');
h.classList.toggle('bt-on', on);
Object.keys(views).forEach(function (id) {
var v = views[id];
var mine = on && String(t.id) === id;
if (v.kind === 'snapshot') showFront(v);
else if (v.el) v.el.classList.toggle('bt-active', mine);
if (v.status) v.status.classList.toggle('bt-show', mine && !!v.status.textContent);
if (mine) applyZoom(v);
});
var id = t ? String(t.id) : null;
if (id !== lastActiveId) {
lastActiveId = id;
// Leaving the tab puts the keyboard away; it comes back with the next tap on a field.
if (!on || !views[id]) hideKeyboard();
}
}
// ------------------------------------------------------------------ the app's keyboard, typing into the page
/*
* The Prayer tab's keyboard, built by the same code (BibleApp.createPrayerKeyboard) so it looks and
* behaves identically — same keys, shift / caps lock, dragging — with a sink that types into the page
* instead of a contenteditable. It lives on <body>, not in the panel, so it survives tab switches.
* Keys never take focus (createPrayerKeyboard handles that when given a sink): the page's field keeps
* its caret, and insertText is a real, trusted text input that frameworks see as typing.
*/
var kb = null;
var sink = {
insertText: function (text) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return;
try { v.el.focus(); v.el.insertText(text); } catch (_) {}
},
key: function (name) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return;
var code = name === 'Enter' ? 'Return' : name;
try {
v.el.focus();
v.el.sendInputEvent({ type: 'keyDown', keyCode: code });
// A real Enter also produces a character event; forms submit on it.
if (name === 'Enter') v.el.sendInputEvent({ type: 'char', keyCode: '\r' });
v.el.sendInputEvent({ type: 'keyUp', keyCode: code });
} catch (_) {}
},
focus: function () {
var v = activeView();
if (v && v.kind === 'webview') { try { v.el.focus(); } catch (_) {} }
}
};
function keyboard() {
if (kb) return kb;
var a = app();
if (!a || typeof a.createPrayerKeyboard !== 'function') return null;
var el = document.createElement('div');
el.id = 'browserKeyboard';
el.className = 'prayer-keyboard browser-keyboard';
el.style.display = 'none';
document.body.appendChild(el);
a.createPrayerKeyboard(el, null, 'browser', sink);
kb = { el: el, placed: false };
return kb;
}
function showKeyboard() {
if (ROLE !== 'stage') return;
var k = keyboard();
if (!k) return;
k.el.style.display = 'block';
// First time: bottom centre, clear of the page's search box at the top. After that, wherever it was dragged.
if (!k.placed) {
k.placed = true;
var w = k.el.offsetWidth || 760, hgt = k.el.offsetHeight || 340;
k.el.style.left = Math.max(0, Math.round((window.innerWidth - w) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round(window.innerHeight - hgt - Math.max(24, window.innerHeight * 0.04))) + 'px';
}
}
function hideKeyboard() { if (kb) kb.el.style.display = 'none'; }
// ------------------------------------------------------------------ booth follower: publishing side
var wantDom = false;
function setWantDom(on) {
if (on === wantDom) return;
wantDom = on;
Object.keys(views).forEach(function (id) {
var v = views[id];
if (v.kind === 'webview' && v.ready) { try { v.el.send('bibleapp:want-dom', on); } catch (_) {} }
});
}
// ------------------------------------------------------------------ public API
function viewFor(tabId) {
var t = tabById(tabId);
return (t && t.mode === 'browser') ? (views[String(t.id)] || null) : null;
}
function firstView() { var t = browserTabs()[0]; return t ? views[String(t.id)] || null : null; }
var gesture = { id: null, base: 0, seq: -1 };
window.__browserTab = {
role: ROLE,
sync: sync,
resetSession: resetSession,
isBrowserTab: function (tabId) { return !!viewFor(tabId); },
/*
* Phone remote + header nav + OSC. Each returns true when it handled the action, so app.js falls
* through to its own Bible behaviour for every other tab.
* edge: 'top' | 'bottom' pages: +1 / -1 (the ±5-verse buttons become a screenful)
*/
scroll: function (tabId, cmd) { var v = viewFor(tabId); return v ? scrollView(v, cmd) : false; },
/*
* The phone's two-finger (on this tab, also one-finger) drag. Same contract as the Bible version in
* app.js: `t` is the gesture's CUMULATIVE travel as a fraction of the window height, restated in every
* packet so a lost one self-heals. The page scrolls in its own (zoomed) pixels, so window travel is
* divided by the zoom: a finger moving 10% of the picture moves the page 10% of the picture.
*/
mirrorScroll: function (tabId, payload) {
var v = viewFor(tabId);
if (!v) return false;
var z = v.zoom || 1;
if (payload.t !== undefined) {
var t = Number(payload.t), seq = Number(payload.seq);
if (!isFinite(t)) return true;
if (gesture.id !== payload.gesture) { gesture.id = payload.gesture; gesture.base = v.scrollY; gesture.seq = -1; }
if (isFinite(seq)) { if (seq <= gesture.seq) return true; gesture.seq = seq; }
scrollView(v, { to: gesture.base - (t * window.innerHeight) / z });
} else {
var dy = Number(payload.dy);
if (isFinite(dy) && dy) scrollView(v, { by: -(dy * window.innerHeight) / z });
}
return true;
},
/*
* Where the page is scrolled, as the phone mirror reads a Bible tab: fractions of the WINDOW height
* (mirror-stream.js), so the phone's local preview moves the picture by the right amount and stops at
* the real bottom of the page.
*/
mirrorScrollState: function (tabId) {
var v = viewFor(tabId);
if (!v || !window.innerHeight) return null;
var z = v.zoom || 1;
return { sc: (v.scrollY * z) / window.innerHeight, sm: (v.scrollMax * z) / window.innerHeight };
},
/*
* A tap on the phone's picture of the TV. elementFromPoint over a page only finds the <webview> itself,
* so the tap is replayed INSIDE the page as real mouse input, where it can press a button, focus the
* search box or open a scripture reference exactly as a finger on the glass would.
*/
tapAt: function (x, y) {
var v = activeView();
if (!v || v.kind !== 'webview' || !v.ready) return false;
var r = v.el.getBoundingClientRect();
if (x < r.left || x > r.right || y < r.top || y > r.bottom) return false;
var opts = { x: Math.round(x - r.left), y: Math.round(y - r.top), button: 'left', clickCount: 1 };
try {
v.el.focus();
v.el.sendInputEvent(Object.assign({ type: 'mouseDown' }, opts));
v.el.sendInputEvent(Object.assign({ type: 'mouseUp' }, opts));
} catch (_) {}
return true;
},
// Recovery — OSC /bibleapp/browser/reload and /home, the admin console, Settings.
reload: function (tabId) { var v = tabId ? viewFor(tabId) : (activeView() || firstView()); reload(v); return !!v; },
home: function (tabId) {
var list = tabId ? [viewFor(tabId)] : (activeView() ? [activeView()] : Object.keys(views).map(function (id) { return views[id]; }));
list.forEach(function (v) { if (v) go(v, startUrl()); });
return list.length > 0;
},
// localStorage changed under us (phone / admin setSetting, or the Settings page in another window).
applySettings: function (key) {
if (key === KEY_URL) { resetSession(); Object.keys(views).forEach(function (id) { go(views[id], startUrl()); }); }
else if (key === KEY_ZOOM) Object.keys(views).forEach(function (id) { applyZoom(views[id]); });
else if (key === KEY_TAPS) Object.keys(views).forEach(function (id) {
var v = views[id];
if (v.kind === 'webview' && v.ready) { try { v.el.send('bibleapp:taps', tapsOn()); } catch (_) {} }
});
},
// ---- follower-publish.js (stage side): the page as the booth needs it
stageState: function (tabId) {
var v = viewFor(tabId);
if (!v) return null;
return { url: v.url, zoom: v.zoom || 1, domVer: v.domVer || 0, scrollY: v.scrollY || 0, inputs: v.inputs || null };
},
stageHtml: function (tabId) { var v = viewFor(tabId); return (v && v.domHtml) ? { ver: v.domVer, html: v.domHtml } : null; },
// ---- follower-view.js (booth side)
applyFollower: function (tabId, b) { followerApply(viewFor(tabId), b); },
applyFollowerScroll: function (tabId, y) { followerApply(viewFor(tabId), { scrollY: Number(y) || 0 }); },
applyFollowerInputs: function (tabId, values) { followerApply(viewFor(tabId), { inputs: values }); },
// Diagnostics (DevTools): __browserTab.debug()
debug: function () {
return { role: ROLE, wantDom: wantDom, start: startUrl(), views: Object.keys(views).map(function (id) {
var v = views[id];
return { id: id, kind: v.kind, url: v.url, ready: v.ready, zoom: v.zoom, scrollY: v.scrollY, scrollMax: v.scrollMax, domVer: v.domVer, htmlBytes: v.domHtml ? v.domHtml.length : 0 };
}) };
}
};
// ------------------------------------------------------------------ boot
function boot() {
if (!app()) { setTimeout(boot, 120); return; } // app.js loads as a module, after this classic script
sync();
/*
* Floor under the hooks in app.js (renderTabs / renderTabContent call sync): a tab change that reaches
* activeTabId by some other road still shows or hides the page within a moment. Cheap — it only acts
* when the active tab id or the set of Browser tabs actually changed.
*/
var lastSig = '';
setInterval(function () {
var a = app();
if (!a) return;
var sig = String(a.activeTabId) + '|' + browserTabs().map(function (t) { return t.id; }).join(',');
if (sig !== lastSig) { lastSig = sig; sync(); }
// Publish the page's DOM only while a booth screen is linked; nobody else needs it.
if (ROLE === 'stage') {
var fp = window.__followerPublish;
setWantDom(!!(fp && typeof fp.isOpen === 'function' && fp.isOpen()));
}
}, 500);
window.addEventListener('resize', function () { var v = activeView(); if (v) applyZoom(v); });
// The Settings page is the same origin: its writes arrive here as storage events if it is ever open
// beside the app rather than in place of it.
window.addEventListener('storage', function (ev) {
if (ev && (ev.key === KEY_URL || ev.key === KEY_ZOOM || ev.key === KEY_TAPS)) window.__browserTab.applySettings(ev.key);
});
log('ready as', ROLE);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();
+14
View File
@@ -82,8 +82,20 @@ export function attachFollowerLink(server, opts = {}) {
if (msg && msg.passageKey && !Array.isArray(msg.strokes) && lastStrokes.has(msg.passageKey)) { if (msg && msg.passageKey && !Array.isArray(msg.strokes) && lastStrokes.has(msg.passageKey)) {
msg.strokes = lastStrokes.get(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; 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 * 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 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. // a screen that joins now. The new publisher's first snapshot arrives within a tick.
lastStrokes.clear(); lastStrokes.clear();
lastBrowser = null;
snapshot = null; snapshot = null;
log(`${marked ? 'stage renderer' : 'local publisher (unmarked)'} connected`); log(`${marked ? 'stage renderer' : 'local publisher (unmarked)'} connected`);
} else { } else {
@@ -177,6 +190,7 @@ export function attachFollowerLink(server, opts = {}) {
if (msg && msg.type === 'snapshot') { if (msg && msg.type === 'snapshot') {
msg.seq = ++seq; msg.seq = ++seq;
remember(msg); remember(msg);
rememberBrowser(msg);
// Live subscribers already hold the strokes; only the RETAINED copy is completed. // Live subscribers already hold the strokes; only the RETAINED copy is completed.
fanOut(JSON.stringify(msg)); fanOut(JSON.stringify(msg));
snapshot = JSON.stringify(complete(msg)); snapshot = JSON.stringify(complete(msg));
+31 -1
View File
@@ -55,7 +55,8 @@
lastScroll: null, lastScroll: null,
lastKey: null, // the passage whose stroke list was last sent (see buildSnapshot) lastKey: null, // the passage whose stroke list was last sent (see buildSnapshot)
liveActive: false, 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; } function app() { return window.bibleApp || null; }
@@ -208,6 +209,22 @@
if (html != null) snap.prayer = html; if (html != null) snap.prayer = html;
} else if (t.mode === 'ask') { } else if (t.mode === 'ask') {
snap.ask = b ? b.askMessages(t.id) : []; 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. // The notes tab tracks which point it is on; harmless elsewhere.
if (typeof t.notesCurrentIndex === 'number') snap.notesIndex = t.notesCurrentIndex; 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 }); } 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). // Maps pinch-zoom / pan, per frame while it changes (a pinch is as continuous as a stroke).
var car = b.carousel(t.id); var car = b.carousel(t.id);
if (car && car.zoom) { 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. * Defined on every copy of the app; both are no-ops while this copy is not publishing.
*/ */
window.__followerPublish = { 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 (_) {} } }, 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(); } clearAll: function () { if (S.open) send({ type: 'clearAll', ts: Date.now() }); this.invalidate(); }
}; };
+4
View File
@@ -265,6 +265,8 @@
if (msg.carousel) applyCarousel(local.id, msg.carousel); if (msg.carousel) applyCarousel(local.id, msg.carousel);
if (typeof msg.prayer === 'string' && b) b.setPrayerHtml(local.id, msg.prayer); if (typeof msg.prayer === 'string' && b) b.setPrayerHtml(local.id, msg.prayer);
if (msg.ask && b) b.setAskMessages(local.id, msg.ask); 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() { function activeLocalTabId() {
@@ -315,6 +317,8 @@
if (msg.type === 'commit') return onCommit(msg); if (msg.type === 'commit') return onCommit(msg);
if (msg.type === 'scroll') { S.stats.scrolls++; return applyScroll(activeLocalTabId(), msg.scrollTop); } if (msg.type === 'scroll') { S.stats.scrolls++; return applyScroll(activeLocalTabId(), msg.scrollTop); }
if (msg.type === 'zoom') return onZoom(msg); 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 // ------------------------------------------------------------------ link
+3
View File
@@ -210,5 +210,8 @@
<!-- ProPresenter Sync tab: slide-reference scanner + the live link client — isolated, additive --> <!-- ProPresenter Sync tab: slide-reference scanner + the live link client — isolated, additive -->
<script src="scripture-scan.js?v=__BUILD__"></script> <script src="scripture-scan.js?v=__BUILD__"></script>
<script src="propresenter-sync.js?v=__BUILD__"></script> <script src="propresenter-sync.js?v=__BUILD__"></script>
<!-- Browser tab: a live web page with no browser chrome (an Electron <webview> on the stage, the stage's
copy of it on the booth follower) — isolated, additive -->
<script src="browser-tab.js?v=__BUILD__"></script>
</body> </body>
</html> </html>
+61
View File
@@ -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
* <webview>. 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) <app name>/<version> 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 () { async function createWindow () {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@@ -205,6 +262,8 @@ async function createWindow () {
webSecurity: true, // Same-origin policy ON. App content is served over http://localhost, webSecurity: true, // Same-origin policy ON. App content is served over http://localhost,
// so file:// access is unnecessary; external APIs used are CORS-enabled. // so file:// access is unnecessary; external APIs used are CORS-enabled.
backgroundThrottling: false, // never throttle timers/paint when idle — first tap always registers backgroundThrottling: false, // never throttle timers/paint when idle — first tap always registers
// The Browser tab's pages (browser-tab.js). Every <webview> is locked down in will-attach-webview below.
webviewTag: true,
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
devTools: true // Enable DevTools for debugging devTools: true // Enable DevTools for debugging
} }
@@ -256,6 +315,8 @@ async function createWindow () {
// Build a minimal Help menu with Content Manager and About options // Build a minimal Help menu with Content Manager and About options
try { setupAppMenu(win); } catch (_) {} 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 // Start the embedded server and load the app via HTTP so relative API routes work
await startLocalHttpServer(); await startLocalHttpServer();
+3
View File
@@ -49,6 +49,9 @@
try { try {
var app = window.bibleApp; var app = window.bibleApp;
var id = app && app.activeTabId; 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; var ra = id ? document.getElementById('readingArea' + id) : null;
if (ra && ra.clientHeight > 0 && window.innerHeight > 0) { if (ra && ra.clientHeight > 0 && window.innerHeight > 0) {
tickScroll = ra.scrollTop / window.innerHeight; tickScroll = ra.scrollTop / window.innerHeight;
+3 -1
View File
@@ -482,7 +482,9 @@
// Laser/spotlight follow the SAME rules as pen/highlighter: only on tabs where drawing is // 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 // 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. // 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() { function activeTabMode() {
try { try {
var a = window.bibleApp; var a = window.bibleApp;
+2 -2
View File
@@ -271,9 +271,9 @@
} }
function sync() { function sync() {
if (!dock) return; 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 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; var show = getState() === 'floating' && HIDE_ON.indexOf(mode) === -1;
dock.style.display = show ? 'flex' : 'none'; dock.style.display = show ? 'flex' : 'none';
if (!show) return; if (!show) return;
+2 -1
View File
@@ -22,7 +22,7 @@
var lastSent = 0; var lastSent = 0;
function modeLabel(mode) { 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'); return map[mode] || (mode ? mode.charAt(0).toUpperCase() + mode.slice(1) : 'Tab');
} }
@@ -92,6 +92,7 @@
var title = modeLabel(t.mode); var title = modeLabel(t.mode);
if (t.mode === 'bible' && t.book) title = t.book + ' ' + (t.chapter || 1); 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.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 * 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 * console needs them to (a) clone the live TV state into its private copy exactly and (b) diff
+37 -15
View File
@@ -1345,6 +1345,30 @@
momentumRaf = requestAnimationFrame(step); 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) { pad.addEventListener('touchstart', function (e) {
e.preventDefault(); e.preventDefault();
var wasCoasting = !!momentumRaf; var wasCoasting = !!momentumRaf;
@@ -1371,16 +1395,7 @@
// the app discards it the same way it discards a two-finger-cancelled stroke on the TV. // the app discards it the same way it discards a two-finger-cancelled stroke on the TV.
if (stroke.sent) queueDraw('cancel', []); if (stroke.sent) queueDraw('cancel', []);
resetStroke(); resetStroke();
scroll.active = true; beginScroll(e.touches, false);
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();
return; return;
} }
if (scroll.active) return; if (scroll.active) return;
@@ -1390,9 +1405,9 @@
var mode = activeTabMode(); var mode = activeTabMode();
var tool = tvToolNow(); var tool = tvToolNow();
// Match the TV's own refusals up front, so the preview never shows a line the TV will not draw: // 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). // 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'); || !(tool === 'pen' || tool === 'highlight' || tool === 'erase');
var r = tvRect(); 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; 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; if (swallowGesture) return;
var now = Date.now(); var now = Date.now();
if (scroll.active) { if (scroll.active) {
if (e.touches.length < 2) return; if (e.touches.length < (scroll.oneFinger ? 1 : 2)) return;
var y = avgY(e.touches); var y = avgY(e.touches);
var movedPx = y - scroll.lastY; var movedPx = y - scroll.lastY;
scroll.lastY = y; scroll.lastY = y;
@@ -1426,6 +1441,13 @@
var p = norm(e.touches[0]); var p = norm(e.touches[0]);
if (!p) return; if (!p) return;
if (stroke.blocked) { 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. // Tell the operator once WHY nothing is happening instead of silently eating the gesture.
if (!stroke.warned) { if (!stroke.warned) {
stroke.warned = true; stroke.warned = true;
@@ -1512,7 +1534,7 @@
hint.textContent = 'Splash is on — tap the screen to hide it'; hint.textContent = 'Splash is on — tap the screen to hide it';
hint.style.opacity = '1'; hint.style.opacity = '1';
} else if (!hintIntro) { } else if (!hintIntro) {
hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll'; hint.textContent = hintText();
hint.style.opacity = '0'; hint.style.opacity = '0';
} }
} }
@@ -1536,7 +1558,7 @@
view.classList.remove('hidden'); view.classList.remove('hidden');
setStatus('connecting…', ''); setStatus('connecting…', '');
hintIntro = true; hintIntro = true;
hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll'; hint.textContent = hintText();
hint.style.opacity = '1'; hint.style.opacity = '1';
setTimeout(function () { hintIntro = false; updateTvHint(); }, 2600); setTimeout(function () { hintIntro = false; updateTvHint(); }, 2600);
updateTvHint(); updateTvHint();
+48 -1
View File
@@ -9,6 +9,9 @@
* scanText(text) — the FIRST "Book chapter:verse(-verse)" inside free prose ("…as it says in * 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 * 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. * 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. * 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 * 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; 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) { function parseField(text) {
var B = books(); var B = books();
var t = stripTranslation(text); var t = stripTranslation(text);
@@ -147,7 +193,8 @@
return null; 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; if (typeof module === 'object' && module.exports) module.exports = API;
root.ScriptureScan = API; root.ScriptureScan = API;
})(typeof globalThis !== 'undefined' ? globalThis : this); })(typeof globalThis !== 'undefined' ? globalThis : this);
+32 -4
View File
@@ -956,7 +956,7 @@ const oscStateUiCache = {
* --------------------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------------------
*/ */
const OSC_TAB_KINDS = ['bible', 'sync', 'whiteboard', 'notes', 'media', 'alpha', 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; const OSC_TAB_SLOTS = 16;
/* /*
@@ -2250,6 +2250,29 @@ function startOscListener() {
sendOscFeedback(msg, info, 'show_maps', !!result, result ? null : 'Renderer invoker returned false'); sendOscFeedback(msg, info, 'show_maps', !!result, result ? null : 'Renderer invoker returned false');
return; 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 (norm === '/bibleapp/chapter/prev' || norm === '/bibleapp/prev_chapter') {
if (!tokenOk) { sendOscFeedback(msg, info, 'chapter_prev', false, 'Token validation failed'); return; } if (!tokenOk) { sendOscFeedback(msg, info, 'chapter_prev', false, 'Token validation failed'); return; }
const result = invokeRenderer('chapterPrev'); const result = invokeRenderer('chapterPrev');
@@ -3882,6 +3905,7 @@ const server = http.createServer(async (req, res) => {
'switchToMode', 'switchTabId', 'switch_tab_left', 'switch_tab_right', 'switchToMode', 'switchTabId', 'switch_tab_left', 'switch_tab_right',
'new_bible_tab', 'new_whiteboard_tab', 'new_notes_tab', 'new_bible_tab', 'new_whiteboard_tab', 'new_notes_tab',
'close_tab', 'close_active_tab', 'showMaps', 'close_tab', 'close_active_tab', 'showMaps',
'showBrowser', 'browserReload', 'browserHome',
// Presentation // Presentation
'splash_show', 'splash_hide', 'splashToggle', 'presentOff', 'splash_show', 'splash_hide', 'splashToggle', 'presentOff',
'laser', 'spotlight', 'redLetterToggle', '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. // 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 // clear_all + the four scroll actions are handled in app.js; they were unreachable from the
// phone until they were listed here. // 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. // 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 // `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}`); res.end(`Server Error: ${error.code}`);
} }
} else { } 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 <webview> 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 = { const headers = {
'Content-Type': contentType, '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 // Disable caching for core app assets to ensure rebuilt changes are picked up
if (/^text\/(?:html|css|javascript)$/.test(contentType)) { if (/^text\/(?:html|css|javascript)$/.test(contentType)) {
+100 -2
View File
@@ -829,7 +829,7 @@
<div style="color:#777; margin-bottom:14px; font-size:13px; line-height:1.9;"> <div style="color:#777; margin-bottom:14px; font-size:13px; line-height:1.9;">
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_active/&lt;n&gt;</code> &mdash; <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1&ndash;16).<br> <code style="color:#8cc88c;">/bibleapp/feedback/state/tab_active/&lt;n&gt;</code> &mdash; <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1&ndash;16).<br>
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/&lt;kind&gt;</code> &mdash; <code>1</code> while a tab of that <strong>kind</strong> is showing:<br> <code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/&lt;kind&gt;</code> &mdash; <code>1</code> while a tab of that <strong>kind</strong> is showing:<br>
<span style="margin-left:18px;">bible &middot; sync &middot; whiteboard &middot; notes &middot; media &middot; alpha &middot; announcements &middot; graphics &middot; maps &middot; prayer &middot; ask</span> <span style="margin-left:18px;">bible &middot; sync &middot; whiteboard &middot; notes &middot; media &middot; alpha &middot; announcements &middot; graphics &middot; maps &middot; browser &middot; prayer &middot; ask</span>
</div> </div>
<label style="display:flex; align-items:center; gap:8px; margin:0;"> <label style="display:flex; align-items:center; gap:8px; margin:0;">
<input type="checkbox" id="oscFeedbackTabStates"> Send per-tab on/off states for Companion button colours <input type="checkbox" id="oscFeedbackTabStates"> Send per-tab on/off states for Companion button colours
@@ -943,6 +943,57 @@
</div> </div>
</div> </div>
<!-- Browser Tab Settings -->
<div class="section">
<h2>Browser Tab</h2>
<p style="color: #999; margin-bottom: 18px;">
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.
</p>
<div class="form-group">
<label for="browserTabUrl">Start page</label>
<input type="url" id="browserTabUrl" placeholder="https://it-is-written-waters-church.vercel.app/" onchange="saveBrowserTabSettings()"
style="width: 100%; max-width: 560px; padding: 10px; background: #1f1f1f; color: #e0e0e0; border: 1px solid #444; border-radius: 6px;">
<div class="help-text">The page the tab opens. Leave blank for It Is Written. Changing it takes the tab to the new page.</div>
</div>
<div class="form-group">
<label for="browserTabZoom">Page size</label>
<select id="browserTabZoom" onchange="saveBrowserTabSettings()"
style="width: 100%; max-width: 360px; padding: 10px; background: #1f1f1f; color: #e0e0e0; border: 1px solid #444; border-radius: 6px;">
<option value="auto">Auto — fit the page to the screen</option>
<option value="75">75%</option>
<option value="100">100%</option>
<option value="125">125%</option>
<option value="150">150%</option>
<option value="175">175%</option>
<option value="200">200%</option>
<option value="250">250%</option>
<option value="300">300%</option>
</select>
<div class="help-text">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.</div>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="browserTabScriptureTaps" onchange="saveBrowserTabSettings()"> Tap a scripture reference on the page to open it in a new Bible tab
</label>
<div class="help-text">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.</div>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<span style="color:#8cc88c; font-size:13px;">✓ Saves automatically</span>
<button type="button" class="btn btn-secondary" onclick="browserTabBackToStart()">Return to the start page</button>
</div>
<div class="help-text" style="margin-top:8px;">
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. <strong>Return to the start page</strong> clears that, as does the refresh button on the TV.
From the booth: OSC <code>/bibleapp/browser/home</code> and <code>/bibleapp/browser/reload</code>.
</div>
</div>
<!-- Ask Tab (AI) Settings --> <!-- Ask Tab (AI) Settings -->
<div class="section"> <div class="section">
<h2>Ask Tab (AI Assistant)</h2> <h2>Ask Tab (AI Assistant)</h2>
@@ -1002,6 +1053,14 @@
<div><strong>/bibleapp/close</strong> - Close active tab</div> <div><strong>/bibleapp/close</strong> - Close active tab</div>
<div><strong>/bibleapp/tab/left</strong> - Switch to previous tab</div> <div><strong>/bibleapp/tab/left</strong> - Switch to previous tab</div>
<div><strong>/bibleapp/tab/right</strong> - Switch to next tab</div> <div><strong>/bibleapp/tab/right</strong> - Switch to next tab</div>
<div><strong>/bibleapp/maps</strong> - Show the Maps &amp; Timelines tab</div>
<div><strong>/bibleapp/browser</strong> - Show the Browser tab</div>
</div>
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Browser tab:</strong></div>
<div style="margin-left: 10px; margin-bottom: 10px;">
<div><strong>/bibleapp/browser/reload</strong> - Reload the page (if it hung or looks wrong)</div>
<div><strong>/bibleapp/browser/home</strong> - Take the page back to its start page (Settings › Browser Tab)</div>
</div> </div>
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Tools:</strong></div> <div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Tools:</strong></div>
@@ -2027,6 +2086,7 @@
{ mode: 'announcements', label: 'Announcements Tab', description: 'Image-based announcements display' }, { mode: 'announcements', label: 'Announcements Tab', description: 'Image-based announcements display' },
{ mode: 'graphics', label: 'Graphics Tab', description: 'Image-based graphics 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: '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: 'whiteboard', label: 'Whiteboard Tab', description: 'Blank whiteboard for drawing' },
{ mode: 'notes', label: 'Notes Tab', description: 'PDF notes with annotations' }, { mode: 'notes', label: 'Notes Tab', description: 'PDF notes with annotations' },
{ mode: 'prayer', label: 'Prayer Tab', description: 'Text editor for prayers and notes' }, { mode: 'prayer', label: 'Prayer Tab', description: 'Text editor for prayers and notes' },
@@ -2791,6 +2851,43 @@
}).join('<br>'); }).join('<br>');
} }
try { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadSplashTriggers); else loadSplashTriggers(); } catch (_) {} 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 (_) {} }); window.addEventListener('storage', (ev) => { try { if (ev && ev.key === 'splashHistory') renderSplashHistory(); } catch (_) {} });
// ---- Follower screen (booth mirror → ProPresenter / ATEM) ---- // ---- Follower screen (booth mirror → ProPresenter / ATEM) ----
@@ -3058,7 +3155,8 @@
'redLetterEnabled', 'redLetterOverrides', 'popupVisibility', 'redLetterEnabled', 'redLetterOverrides', 'popupVisibility',
'translationVisibility', 'wordPopupsEnabled', 'spotlightHalfPx', 'translationVisibility', 'wordPopupsEnabled', 'spotlightHalfPx',
'userLaserColor', 'toolWheelEnabled', 'toolWheelStyle', 'toolSnapBackSec', 'userLaserColor', 'toolWheelEnabled', 'toolWheelStyle', 'toolSnapBackSec',
'idleSplashSeconds', 'fiveFingerSplash', 'ppsyncSeparateAnnotations'].forEach(function (k) { 'idleSplashSeconds', 'fiveFingerSplash', 'ppsyncSeparateAnnotations',
'browserTabUrl', 'browserTabZoom', 'browserTabScriptureTaps'].forEach(function (k) {
var v = localStorage.getItem(k); var v = localStorage.getItem(k);
if (v !== null) settings[k] = v; if (v !== null) settings[k] = v;
}); });
+1 -1
View File
@@ -79,7 +79,7 @@
laser: '#laserToolBtn', spotlight: '#spotlightToolBtn' laser: '#laserToolBtn', spotlight: '#spotlightToolBtn'
}; };
var FALLBACK_COLOR = { pen: '#ffff00', highlight: '#ffff00', erase: '#ffb6c1', laser: '#ffff00', spotlight: '#e6e6e6' }; 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 log() { try { console.log.apply(console, ['[TOOL WHEEL]'].concat([].slice.call(arguments))); } catch (_) {} }
function guards() { return window.__gestureGuards || null; } function guards() { return window.__gestureGuards || null; }