Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ec22d466 | ||
|
|
e7709e8756 | ||
|
|
478763c60e | ||
|
|
f0af453020 | ||
|
|
5e1402118b | ||
|
|
e7e527197f | ||
|
|
a0c3921edd |
@@ -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.
|
||||||
@@ -70,6 +70,12 @@ Notes:
|
|||||||
for you. The TV's updater compares versions numerically and only offers a HIGHER version.
|
for you. The TV's updater compares versions numerically and only offers a HIGHER version.
|
||||||
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment,
|
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment,
|
||||||
`build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`).
|
`build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`).
|
||||||
|
- **Keep `build/installerIcon.ico`.** On macOS electron-builder rebuilds the uninstaller in JavaScript
|
||||||
|
and leaves out NSIS's icon patch; if the installer and uninstaller icons differ, the uninstaller fails
|
||||||
|
NSIS's integrity check, and that version can then never be updated or uninstalled. This file makes
|
||||||
|
both use one icon (NSIS's own default, so nothing looks different). `build-win.cjs` refuses to build
|
||||||
|
without it. Versions 1.4.35–1.4.37 shipped without it; `build/installer.nsh` installs over their
|
||||||
|
damaged uninstallers instead of running them.
|
||||||
- On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the
|
- On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the
|
||||||
terminal as administrator or turn on Developer Mode, delete
|
terminal as administrator or turn on Developer Mode, delete
|
||||||
`%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again.
|
`%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again.
|
||||||
|
|||||||
@@ -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
@@ -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'); })
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -4,3 +4,71 @@
|
|||||||
IfFileExists "$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" 0 +2
|
IfFileExists "$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" 0 +2
|
||||||
ExecWait '"$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" /install /quiet /norestart' $0
|
ExecWait '"$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" /install /quiet /norestart' $0
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------------------------------
|
||||||
|
; Replaces electron-builder's "is the app running?" check (customCheckAppRunning). Runs at the start of
|
||||||
|
; installing, BEFORE the old version is removed and before any file is copied.
|
||||||
|
;
|
||||||
|
; The stock check only looks for "Bible App.exe". But the app also starts programs from its own folder
|
||||||
|
; - the local AI server (llama-server.exe), speech (whisper), the upscaler - and if the app is closed
|
||||||
|
; hard, those are left running. They hold their files open, so copying the new version fails and the
|
||||||
|
; installer says "Bible App cannot be closed. Please close it manually and click Retry" even though
|
||||||
|
; the app is plainly closed, and Retry can never succeed. So:
|
||||||
|
; 1. ask the app to close (it then stops the AI server itself), and give it a moment;
|
||||||
|
; 2. force-close anything named Bible App.exe that is still there;
|
||||||
|
; 3. force-close the app's own helper programs by name (plain taskkill, nothing to go wrong);
|
||||||
|
; 4. as a backstop, force-close EVERY program running from this install folder, whatever its name.
|
||||||
|
; Step 4 matches by folder, via WMI (works from this 32-bit installer on 64-bit Windows, where a
|
||||||
|
; 32-bit process cannot read a 64-bit process's path). The folder travels in an environment variable
|
||||||
|
; so a quote or space in it can never break the command, and it refuses to act on a bare drive.
|
||||||
|
; No question is asked first: installing this version replaces the app, which is what was asked for.
|
||||||
|
; ---------------------------------------------------------------------------------------------------
|
||||||
|
!macro customCheckAppRunning
|
||||||
|
DetailPrint `Closing "${PRODUCT_NAME}"...`
|
||||||
|
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"`
|
||||||
|
Pop $0
|
||||||
|
Sleep 2500
|
||||||
|
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /t /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"`
|
||||||
|
Pop $0
|
||||||
|
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /t /im llama-server.exe /im whisper-cli.exe /im realesrgan-ncnn-vulkan.exe /fi "USERNAME eq %USERNAME%"`
|
||||||
|
Pop $0
|
||||||
|
System::Call 'Kernel32::SetEnvironmentVariable(t "BIBLEAPP_INSTDIR", t "$INSTDIR")'
|
||||||
|
StrCpy $1 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
${If} ${FileExists} "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
StrCpy $1 "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
${EndIf}
|
||||||
|
StrCpy $2 `"$1" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$r = $$env:BIBLEAPP_INSTDIR.TrimEnd('\'); if ($$r.Split('\').Count -lt 2) { exit }; $$d = $$r + '\'; Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$d, [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
|
||||||
|
DetailPrint `Closing anything else still running from "${PRODUCT_NAME}"'s folder...`
|
||||||
|
nsExec::Exec $2
|
||||||
|
Pop $0
|
||||||
|
Sleep 1000
|
||||||
|
!insertmacro bibleAppSkipDamagedUninstaller SHELL_CONTEXT
|
||||||
|
!insertmacro bibleAppSkipDamagedUninstaller HKCU
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------------------------------
|
||||||
|
; Versions 1.4.35 - 1.4.37 were built on macOS before build/installerIcon.ico existed, and their
|
||||||
|
; uninstallers are damaged: they fail NSIS's own integrity check ("Installer integrity check has
|
||||||
|
; failed"). electron-builder rebuilds the uninstaller in JavaScript on macOS and leaves out NSIS's
|
||||||
|
; icon patch, so whenever the installer and uninstaller icons differ (NSIS's two defaults do), the
|
||||||
|
; checksum is wrong. An update runs the PREVIOUS version's uninstaller first, so those versions could
|
||||||
|
; never be updated: the damaged uninstaller fails, and the installer reports "Bible App cannot be
|
||||||
|
; closed" (it also cannot be uninstalled from Windows Settings).
|
||||||
|
;
|
||||||
|
; For exactly those versions, the old uninstaller is not run: its UninstallString is cleared, so
|
||||||
|
; electron-builder's uninstallOldVersion finds nothing to run, and the new files are copied over the
|
||||||
|
; old ones. This installer then writes a fresh, valid uninstaller and registry entry. Every other
|
||||||
|
; version still goes through the normal uninstall-then-install path.
|
||||||
|
; ---------------------------------------------------------------------------------------------------
|
||||||
|
!macro bibleAppSkipDamagedUninstaller ROOT
|
||||||
|
ReadRegStr $3 ${ROOT} "${UNINSTALL_REGISTRY_KEY}" DisplayVersion
|
||||||
|
${If} $3 == "1.4.35"
|
||||||
|
${OrIf} $3 == "1.4.36"
|
||||||
|
${OrIf} $3 == "1.4.37"
|
||||||
|
DetailPrint `Version $3's uninstaller is damaged; installing over it instead.`
|
||||||
|
DeleteRegValue ${ROOT} "${UNINSTALL_REGISTRY_KEY}" UninstallString
|
||||||
|
!ifdef UNINSTALL_REGISTRY_KEY_2
|
||||||
|
DeleteRegValue ${ROOT} "${UNINSTALL_REGISTRY_KEY_2}" UninstallString
|
||||||
|
!endif
|
||||||
|
${EndIf}
|
||||||
|
!macroend
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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
@@ -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(); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -294,6 +355,9 @@ async function createWindow () {
|
|||||||
quit: () => { try { app.quit(); } catch (_) {} },
|
quit: () => { try { app.quit(); } catch (_) {} },
|
||||||
runInstaller: (installerPath) => {
|
runInstaller: (installerPath) => {
|
||||||
// Launch the installer detached, then quit so it can replace the running app.
|
// Launch the installer detached, then quit so it can replace the running app.
|
||||||
|
// Stop the local AI server FIRST: it runs from the install folder, and one left running holds
|
||||||
|
// its files open, so the installer cannot copy the new version over them ("cannot be closed").
|
||||||
|
try { stopLlamaServer(); } catch (_) {}
|
||||||
try {
|
try {
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore', windowsHide: false });
|
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore', windowsHide: false });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bible-app",
|
"name": "bible-app",
|
||||||
"version": "1.4.35",
|
"version": "1.4.38",
|
||||||
"description": "Minimalist Bible App for Portrait-Oriented Touchscreen TV",
|
"description": "Minimalist Bible App for Portrait-Oriented Touchscreen TV",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "main.cjs",
|
"main": "main.cjs",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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/<n></code> — <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1–16).<br>
|
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_active/<n></code> — <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1–16).<br>
|
||||||
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/<kind></code> — <code>1</code> while a tab of that <strong>kind</strong> is showing:<br>
|
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/<kind></code> — <code>1</code> while a tab of that <strong>kind</strong> is showing:<br>
|
||||||
<span style="margin-left:18px;">bible · sync · whiteboard · notes · media · alpha · announcements · graphics · maps · prayer · ask</span>
|
<span style="margin-left:18px;">bible · sync · whiteboard · notes · media · alpha · announcements · graphics · maps · browser · prayer · 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 & 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
@@ -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; }
|
||||||
|
|||||||
@@ -12,8 +12,14 @@
|
|||||||
* - a clean signing environment. There is no code-signing certificate. electron-builder aborts on
|
* - a clean signing environment. There is no code-signing certificate. electron-builder aborts on
|
||||||
* an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed
|
* an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed
|
||||||
* rather than blanked, and identity auto-discovery is turned off.
|
* rather than blanked, and identity auto-discovery is turned off.
|
||||||
|
* - a working uninstaller. On macOS electron-builder rebuilds the NSIS uninstaller in JavaScript and
|
||||||
|
* skips NSIS's icon patch, so unless the installer and uninstaller use the SAME icon the uninstaller
|
||||||
|
* fails NSIS's integrity check — and then that version can never be updated or uninstalled
|
||||||
|
* (1.4.35-1.4.37 shipped like that). build/installerIcon.ico makes electron-builder use one icon for
|
||||||
|
* both, so this refuses to build without it.
|
||||||
*/
|
*/
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' };
|
const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' };
|
||||||
@@ -21,6 +27,13 @@ for (const key of ['CSC_LINK', 'WIN_CSC_LINK', 'CSC_KEY_PASSWORD', 'WIN_CSC_KEY_
|
|||||||
delete env[key];
|
delete env[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ICON = path.join(__dirname, '..', 'build', 'installerIcon.ico');
|
||||||
|
if (!fs.existsSync(ICON)) {
|
||||||
|
console.error('build/installerIcon.ico is missing. Without it the uninstaller built on macOS is damaged');
|
||||||
|
console.error('("Installer integrity check has failed") and the version could never be updated. See tools/build-win.cjs.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const extra = process.argv.slice(2);
|
const extra = process.argv.slice(2);
|
||||||
const args = ['electron-builder', '--win', '--x64', ...extra];
|
const args = ['electron-builder', '--win', '--x64', ...extra];
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,24 @@ async function giteaPrune(baseUrl, token, keepFile, keepPrevious = 1) {
|
|||||||
let files;
|
let files;
|
||||||
try { files = JSON.parse(res.body); } catch (_) { return; }
|
try { files = JSON.parse(res.body); } catch (_) { return; }
|
||||||
if (!Array.isArray(files)) return;
|
if (!Array.isArray(files)) return;
|
||||||
|
// Rank by the version in the filename, not by created_at: this server returns that field empty,
|
||||||
|
// which would leave every build "equally old" and quietly retain the OLDEST one as the rollback
|
||||||
|
// copy instead of the newest.
|
||||||
|
const verOf = (name) => {
|
||||||
|
const m = String(name).match(/(\d+(?:\.\d+)+)/);
|
||||||
|
return m ? m[1].split('.').map(n => parseInt(n, 10) || 0) : [];
|
||||||
|
};
|
||||||
|
const newerFirst = (a, b) => {
|
||||||
|
const pa = verOf(a.name), pb = verOf(b.name);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const x = pa[i] || 0, y = pb[i] || 0;
|
||||||
|
if (x !== y) return y - x;
|
||||||
|
}
|
||||||
|
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
|
||||||
|
};
|
||||||
const stale = files
|
const stale = files
|
||||||
.filter(f => f && typeof f.name === 'string' && f.name.toLowerCase().endsWith('.exe') && f.name !== keepFile)
|
.filter(f => f && typeof f.name === 'string' && f.name.toLowerCase().endsWith('.exe') && f.name !== keepFile)
|
||||||
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
|
.sort(newerFirst)
|
||||||
.slice(keepPrevious);
|
.slice(keepPrevious);
|
||||||
for (const f of stale) {
|
for (const f of stale) {
|
||||||
const del = await giteaRequest('DELETE', baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(f.name), token, null);
|
const del = await giteaRequest('DELETE', baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(f.name), token, null);
|
||||||
|
|||||||
Reference in New Issue
Block a user