Files
bible-app/propresenter-sync.js
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

451 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* propresenter-sync.js — the ProPresenter Sync tab, renderer side. Isolated + additive.
*
* app.js owns the tab itself: a REAL Bible tab (mode 'bible', flagged `ppsync: true`) created at startup
* and pinned left of the first Bible tab. Because it is a Bible tab, drawing, scrolling, word popups,
* annotations, selectors — everything — behave exactly as on any other Bible tab. This file does ONE
* thing: it makes the tab FOLLOW ProPresenter.
*
* server (propresenter-link.js) ──SSE /api/propresenter/events──▶ here
* ▶ scripture-scan.js turns the slide (label → notes → text) into a reference
* ▶ the tab is moved to that book/chapter/verse through the SAME fields and loadPassage() call a
* finger on a verse card uses — so highlighting, scrolling, annotations and popups all behave
* ▶ a cross-fade covers the swap (60ms out · 160ms in; instant under prefers-reduced-motion)
*
* On stage the tab must look like any Bible tab, so this adds NOTHING to the screen: the whole chapter
* is loaded and the header reads "John 3 ESV" exactly as on a Bible tab (never the slide's verse or
* range — the tab only SCROLLS to the first verse of the reference). No state dots, no verse tint, no
* banners, no controls. The link state is read in Settings › ProPresenter Sync and on Companion buttons
* (/bibleapp/feedback/state/ppsync_*), nowhere else.
*
* Rules that matter live on stage:
* - A slide with no scripture (song, announcement) changes NOTHING: the last verse stays up.
* - A drop keeps the last verse too.
* - The tab follows ProPresenter even if the operator browsed elsewhere on it (no lock mode, v1).
* - While another tab is active, the sync tab still moves in the background — the passage is
* loaded the moment the operator switches to it (switchTab's Bible branch in app.js).
*/
(function () {
'use strict';
var EVENTS_URL = '/api/propresenter/events';
var CROSSFADE_MS = 220; // the old scripture fades out over the fully rendered, fully still new one
var SETTLE_MAX_MS = 2600; // how long to wait for the new chapter to render, scroll and stop moving
var SNAP_MAX_MS = 3500; // a snapshot never outlives this, whatever happens to the load
var BURST_WINDOW_MS = 150;
var S = {
server: null, // last snapshot from the server link
status: 'idle',
serverDown: false, // the app's own server stopped answering (not ProPresenter)
slideKey: null,
ref: null, // the reference currently live in ProPresenter (last valid one)
refSource: null, // 'label' | 'notes' | 'text'
clamped: null, // { asked, shown } when a verse number ran past the chapter (logged only)
noScripture: false, // the current slide carries no reference
structure: null, // bibleStructure (verse counts) — loaded lazily
pending: null, burst: [], timer: null,
fadeToken: 0, snap: null, // snap: the snapshot overlay of the outgoing scripture (see takeSnapshot)
es: null, esRetry: null
};
function app() { return window.bibleApp || null; }
// The sync tab is the Bible tab carrying the `ppsync` flag (app.js createPPSyncTab).
function getTab() {
var a = app();
if (!a || !a.tabs) return null;
for (var i = 0; i < a.tabs.length; i++) if (a.tabs[i].ppsync) return a.tabs[i];
return null;
}
function isActiveTab(t) { var a = app(); t = t || getTab(); return !!(a && t && a.activeTabId === t.id && document.getElementById('readingArea' + t.id)); }
function reducedMotion() { try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (_) { return false; } }
function log() { try { console.log.apply(console, ['[PPSYNC]'].concat([].slice.call(arguments))); } catch (_) {} }
function warn() { try { console.warn.apply(console, ['[PPSYNC]'].concat([].slice.call(arguments))); } catch (_) {} }
function currentStatus() { return S.serverDown ? 'disconnected' : (S.status || 'idle'); }
// ------------------------------------------------------------------ server link (SSE)
function connectEvents() {
if (S.es) { try { S.es.close(); } catch (_) {} S.es = null; }
if (typeof EventSource !== 'function') { warn('EventSource unavailable; sync cannot run in this browser'); return; }
var es;
try { es = new EventSource(EVENTS_URL); } catch (e) { scheduleEventsRetry(); return; }
S.es = es;
es.onopen = function () { S.serverDown = false; };
es.onmessage = function (ev) {
var snap = null;
try { snap = JSON.parse(ev.data); } catch (_) { return; }
if (snap && typeof snap === 'object') onSnapshot(snap);
};
es.onerror = function () {
// EventSource retries on its own while CONNECTING; CLOSED means it gave up (server gone).
if (es.readyState === 2) { S.serverDown = true; scheduleEventsRetry(); }
else if (!S.serverDown && es.readyState === 0) { S.serverDown = true; }
};
}
function scheduleEventsRetry() {
if (S.esRetry) return;
S.esRetry = setTimeout(function () { S.esRetry = null; connectEvents(); }, 2000);
}
function onSnapshot(snap) {
S.server = snap;
S.status = snap.status || 'idle';
S.serverDown = false;
// Identity is seq + timestamp: the server's seq restarts at 1 when it restarts, so seq alone could
// make a new slide look like the one already handled.
var slide = snap.slide;
var key = slide ? (String(slide.seq) + ':' + String(slide.ts)) : null;
if (slide && key !== S.slideKey) {
S.slideKey = key;
queueSlide(slide);
}
}
/*
* Rapid advance (PDR §09): three or more slide events inside 150ms means the presenter is clicking
* through — wait for the burst to end and act on the LAST slide only. Otherwise act at once.
*/
function queueSlide(slide) {
var now = Date.now();
S.burst = S.burst.filter(function (t) { return now - t < BURST_WINDOW_MS; });
S.burst.push(now);
S.pending = slide;
if (S.timer) clearTimeout(S.timer);
var wait = S.burst.length >= 3 ? BURST_WINDOW_MS : 15;
S.timer = setTimeout(function () {
S.timer = null;
var s = S.pending; S.pending = null;
if (s) processSlide(s);
}, wait);
}
function processSlide(slide) {
var parsed = null;
try { parsed = window.ScriptureScan ? window.ScriptureScan.parseSlide(slide) : null; } catch (e) { warn('parse failed:', e); }
if (!parsed) {
S.noScripture = true;
log('slide has no scripture reference — holding', describeSlide(slide));
return;
}
var r = clampRef(parsed.ref);
if (!r) {
S.noScripture = true;
warn('reference is out of range for this Bible — holding the last verse:', parsed.ref.canonical);
return;
}
if (S.clamped) warn('verse ' + S.clamped.asked + ' does not exist in ' + r.book + ' ' + r.chapter + ' — showing verse ' + S.clamped.shown);
S.noScripture = false;
S.ref = r;
S.refSource = parsed.source;
// A new scripture arriving while the sync tab is on the TV counts as activity: keep the idle
// splash from covering the verse. An already-showing splash is left alone.
try { if (isActiveTab() && typeof window.__resetIdleTimer === 'function') window.__resetIdleTimer(); } catch (_) {}
navigateTo(r);
}
function describeSlide(slide) {
try {
var bits = [];
if (slide.presentation) bits.push('"' + slide.presentation + '"');
if (slide.index >= 0) bits.push('slide ' + (slide.index + 1));
if (slide.label) bits.push('label "' + slide.label + '"');
var txt = String(slide.text || '').replace(/\s+/g, ' ').trim();
if (txt) bits.push('text "' + txt.slice(0, 50) + (txt.length > 50 ? '…' : '') + '"');
return bits.join(', ');
} catch (_) { return ''; }
}
/*
* Chapter must exist; a verse past the end of the chapter is clamped to the last verse (PDR §09) —
* the header then simply shows the verse that is really on screen. Without the structure table (not
* loaded yet) the reference passes through and loadPassage's own closest-verse logic takes over.
*/
function clampRef(ref) {
S.clamped = null;
var chapters = S.structure && S.structure[ref.book];
if (!Array.isArray(chapters)) return ref;
if (ref.chapter > chapters.length) return null;
var max = chapters[ref.chapter - 1] || 0;
var v = ref.verse, ve = ref.verseEnd;
if (v && max && v > max) { S.clamped = { asked: v, shown: max }; v = max; ve = null; }
if (ve && max && ve > max) ve = max;
if (ve && v && ve <= v) ve = null;
var canonical = ref.book + ' ' + ref.chapter + (v ? ':' + v + (ve ? '-' + ve : '') : '');
return { book: ref.book, chapter: ref.chapter, verse: v, verseEnd: ve, canonical: canonical };
}
// ------------------------------------------------------------------ navigation
function hasText(t) {
var bt = document.getElementById('bibleText' + t.id);
if (!bt) return false;
var txt = (bt.textContent || '').trim();
return txt.length > 0 && !/^No text available/i.test(txt);
}
/*
* Same fields, same call, as the phone remote's go-to: the WHOLE chapter (verse 1, no verseEnd — so
* the header reads "John 3 ESV" exactly like any Bible tab, never a verse or range), loaded=false,
* _scrollToVerse = the first verse of the slide's reference, then loadPassage(). savedText is cleared
* so switchTab can never restore a stale chapter over the live one.
*
* Same chapter already on screen (a range on the next slide, or the presenter stepping back): no
* reload and no fade — only the scroll moves, to the new first verse.
*/
function navigateTo(ref) {
var t = getTab(), a = app();
if (!t || !a || typeof a.loadPassage !== 'function') return;
var wantVerse = ref.verse || 1;
var sameChapter = t.book === ref.book && Number(t.chapter) === ref.chapter;
var active = isActiveTab(t);
if (sameChapter && t.loaded && (!active || hasText(t))) {
if (!active) {
t._ppsyncPendingVerse = wantVerse; // scrolled when the operator switches to the tab (onTabShown)
log('same chapter (background) — will scroll to', ref.canonical);
return;
}
// Same chapter on screen: still a cross-fade, never a visible scroll jump — the snapshot holds the
// old view while the scroll moves underneath, then fades.
var tok = ++S.fadeToken;
if (!S.snap) S.snap = takeSnapshot(t);
rearmScroll(t, wantVerse);
waitSettled(t, wantVerse, SETTLE_MAX_MS).then(function () { if (tok === S.fadeToken) releaseSnapshot(); });
log('same chapter — scroll to', ref.canonical);
return;
}
var apply = function () {
t.book = ref.book;
t.chapter = ref.chapter;
t.verse = 1;
t.verseEnd = null;
t.loaded = false;
t.savedText = null;
t.savedHeader = null;
t.isSelectingVerses = false;
t.scrollTop = 0;
t._pendingScrollRestore = false;
t._scrollToVerse = wantVerse > 1 ? wantVerse : null;
t._jumpRetries = 0;
t._ppsyncPendingVerse = null;
};
if (!active) { apply(); log('moved in background to', ref.canonical); return; }
/*
* Cross-fade with no gap: a snapshot of what is on screen right now is laid over the panel, the new
* chapter loads and scrolls underneath it, and only then does the snapshot fade away — so the
* congregation never sees a black panel or a half-loaded chapter. A newer slide arriving mid-way
* keeps the same snapshot up until ITS chapter is ready.
*/
var token = ++S.fadeToken;
if (!S.snap) S.snap = takeSnapshot(t);
apply();
try { a.loadPassage(t.id); } catch (e) { warn('loadPassage failed:', e); }
armScroll(t);
waitSettled(t, wantVerse, SETTLE_MAX_MS).then(function () {
if (token !== S.fadeToken) return; // superseded — the newer slide releases the snapshot
releaseSnapshot();
});
log('→', ref.canonical, '(from slide ' + S.refSource + ')');
}
// Fresh chapters can outlive the scroller's first retries; re-arm like the remote's go-to does.
function armScroll(t) {
var a = app();
[900, 1800, 3200, 5000].forEach(function (ms) {
setTimeout(function () {
try { if (t._scrollToVerse && a && typeof a.autoScrollToSelectedVerse === 'function') { t._jumpRetries = 0; a.autoScrollToSelectedVerse(t.id); } } catch (_) {}
}, ms);
});
}
// Same passage again (e.g. the operator scrolled away, slide re-fired): bring the verse back into view.
function rearmScroll(t, verse) {
if (!verse || verse <= 1) {
// Verse 1 is the top of the chapter: the app's scroller has nothing to find, so just go there.
try { var ra = document.getElementById('readingArea' + t.id); if (ra) ra.scrollTop = 0; } catch (_) {}
return;
}
var a = app();
try { t._scrollToVerse = verse; t._jumpRetries = 0; if (a && typeof a.autoScrollToSelectedVerse === 'function') a.autoScrollToSelectedVerse(t.id); } catch (_) {}
}
/*
* Resolve only when the new chapter is completely STILL under the snapshot: text present, the wanted
* verse in the DOM, the app's own scroll-to-verse finished (_scrollToVerse consumed — its second pass
* re-positions 120 ms after the first), the text fully opaque with no animation running on it, and the
* scroll position unchanged across two polls. Anything less and the snapshot's fade would reveal text
* that is still sliding, fading or being re-scrolled — which reads as a jump. Timers, not
* requestAnimationFrame: rAF never fires while the window is hidden and the swap must complete anyway.
*/
function waitSettled(t, wantVerse, maxMs) {
var started = Date.now();
var lastTop = null, stillPolls = 0;
return new Promise(function (resolve) {
(function poll() {
var elapsed = Date.now() - started;
var bt = document.getElementById('bibleText' + t.id);
var ra = document.getElementById('readingArea' + t.id);
var textOk = hasText(t) && t.loaded;
var verseOk = wantVerse <= 1 || !!document.getElementById('v-' + t.id + '-' + wantVerse) || elapsed > maxMs * 0.6;
var scrollDone = !t._scrollToVerse;
var visible = false, quiet = false;
if (bt) {
try {
var cs = window.getComputedStyle(bt);
visible = cs.visibility !== 'hidden' && parseFloat(cs.opacity) >= 0.99;
quiet = typeof bt.getAnimations !== 'function' || bt.getAnimations().length === 0;
} catch (_) { visible = true; quiet = true; }
}
var top = ra ? ra.scrollTop : null;
stillPolls = (top !== null && top === lastTop) ? stillPolls + 1 : 0;
lastTop = top;
var settled = textOk && verseOk && scrollDone && visible && quiet && stillPolls >= 2;
if (settled || elapsed > maxMs) {
// Kept for support: how the last swap ended (PPSync.lastSwap in the console).
S.lastSwap = { at: Date.now(), waitedMs: elapsed, settled: settled, timedOut: !settled, textOk: textOk, verseOk: verseOk,
scrollDone: scrollDone, visible: visible, quiet: quiet, scrollTop: top, wantVerse: wantVerse };
setTimeout(resolve, 30);
return;
}
setTimeout(poll, 30);
})();
});
}
// ------------------------------------------------------------------ cross-fade (PDR §06, no gap)
/*
* A snapshot is a deep clone of the sync tab's panel — header, chapter text, annotation strokes,
* open popups, at the same scroll position — laid over the panel, opaque, ignoring touches. The real
* panel loads the new chapter underneath it; releaseSnapshot() then fades the clone out over the
* finished chapter. Ids are stripped so no app code can ever mistake the clone for the live panel,
* and it always removes itself within SNAP_MAX_MS even if the load never settles.
*/
function takeSnapshot(t) {
try {
var ra = document.getElementById('readingArea' + t.id);
var panel = ra ? ra.closest('.tab-panel') : null;
if (!panel) return null;
var snap = panel.cloneNode(true);
snap.removeAttribute('id');
try { delete snap.dataset.tabId; } catch (_) {}
snap.className = 'ppsync-snapshot';
var withId = snap.querySelectorAll('[id]');
for (var i = 0; i < withId.length; i++) withId[i].removeAttribute('id');
// cloneNode leaves canvases blank — copy the pixels so the drawn strokes are part of the picture.
var srcC = panel.querySelectorAll('canvas'), dstC = snap.querySelectorAll('canvas');
for (var k = 0; k < srcC.length && k < dstC.length; k++) {
try { var ctx = dstC[k].getContext('2d'); if (ctx && srcC[k].width && srcC[k].height) ctx.drawImage(srcC[k], 0, 0); } catch (_) {}
}
if (window.getComputedStyle(panel).position === 'static') panel.style.position = 'relative';
// Same box and the same flex-column layout as an active .tab-panel, so the clone's header and reading
// area land exactly where the originals are.
snap.style.cssText = 'position:absolute;left:0;top:0;right:0;bottom:0;z-index:2147483000;pointer-events:none;background:#000;overflow:hidden;margin:0;opacity:1;display:flex;flex-direction:column;';
panel.appendChild(snap);
// scrollTop is not cloned: put the copy exactly where the operator is looking.
var cra = snap.querySelector('.reading-area');
if (cra) cra.scrollTop = ra.scrollTop;
// (The app's own reveal — smoothFadeIn: fade + 6px slide up — is permanently off on the sync tab's
// panel via styles.css `.ppsync-panel .smooth-fade-in`, so text under a snapshot never moves and
// nothing restarts when the snapshot goes.)
setTimeout(function () { if (S.snap === snap) releaseSnapshot(); }, SNAP_MAX_MS);
return snap;
} catch (e) { warn('snapshot failed — swapping without a fade:', e); return null; }
}
function releaseSnapshot(instant) {
var s = S.snap; S.snap = null;
if (!s) return;
var removed = false;
var done = function () {
if (removed) return; removed = true;
try { if (s.parentNode) s.parentNode.removeChild(s); } catch (_) {}
};
if (instant || reducedMotion() || typeof s.animate !== 'function') { done(); return; }
try {
var an = s.animate([{ opacity: 1 }, { opacity: 0 }], { duration: CROSSFADE_MS, easing: 'ease-out', fill: 'forwards' });
an.onfinish = done;
an.oncancel = done;
} catch (_) { done(); return; }
// Animation callbacks stall while the window is hidden/occluded; a plain timer backs the fade.
setTimeout(done, CROSSFADE_MS + 200);
}
// ------------------------------------------------------------------ hooks from app.js
// Called right after the sync tab's panel is built (renderTabContent). Nothing to add to the screen.
function onPanelRendered() {}
/*
* Called from switchTab once the sync tab is on screen (its passage may still be loading).
* loadedNow — switchTab has just called loadPassage because the link moved the tab to a
* different chapter while it sat in the background.
* _ppsyncPendingVerse — the link moved within the chapter already on the tab while it was in the
* background (navigateTo's same-chapter branch).
* Either way the live verse is brought into view the same way the on-screen path does (armScroll):
* loadPassage's own first scroll attempt can run before the fresh chapter has laid out. A plain switch
* back to the tab (neither set) keeps the operator's own scroll position, like any Bible tab.
*/
function onTabShown(tabId, loadedNow) {
var t = getTab();
if (!t || String(t.id) !== String(tabId)) return;
if (S.snap) releaseSnapshot(true); // a snapshot from before the operator switched away is stale
var pending = t._ppsyncPendingVerse || null;
t._ppsyncPendingVerse = null;
if (!loadedNow && !pending) return;
var ref = S.ref;
if (!ref || ref.book !== t.book || Number(t.chapter) !== ref.chapter) return;
var verse = pending || ref.verse || 1;
if (!(verse > 1)) return;
[900, 1800, 3200].forEach(function (ms) {
setTimeout(function () {
try {
if (S.ref !== ref || !isActiveTab(t) || verseInView(t, verse)) return;
rearmScroll(t, verse);
} catch (_) {}
}, ms);
});
}
function verseInView(t, verse) {
var ra = document.getElementById('readingArea' + t.id);
var el = document.getElementById('v-' + t.id + '-' + verse);
if (!ra || !el) return false;
var r = ra.getBoundingClientRect(), v = el.getBoundingClientRect();
return v.top >= r.top - 4 && v.top < r.bottom - 40;
}
// ------------------------------------------------------------------ boot
function loadStructure() {
try {
import('./bible-structure.js').then(function (m) { S.structure = m && m.bibleStructure ? m.bibleStructure : null; })
.catch(function (e) { warn('bible-structure unavailable (verse clamping off):', e && e.message || e); });
} catch (e) { warn('dynamic import unsupported (verse clamping off)'); }
}
function boot() {
/*
* The booth follower renders the sync tab from the STAGE's published state (follower-view.js) and
* must not run a link of its own: it would move the tab a beat before or after the stage does, and
* processSlide's idle-timer reset armed the follower's own splash on every slide (see init.js).
* window.PPSync stays defined so switchTab's onTabShown hook is harmless there (no ref → no-op).
*/
if (window.__followerMode) { log('follower screen — link client off; the tab mirrors the stage'); return; }
var a = app();
if (!a || !a.tabs || typeof a.loadPassage !== 'function') { setTimeout(boot, 120); return; }
if (!window.ScriptureScan || !window.BibleBooks) { warn('scripture-scan.js / book-aliases.js not loaded; retrying'); setTimeout(boot, 250); return; }
loadStructure();
connectEvents();
log('ready');
}
window.PPSync = {
get status() { return currentStatus(); },
get ref() { return S.ref; },
get lastSwap() { return S.lastSwap || null; },
onPanelRendered: onPanelRendered,
onTabShown: onTabShown,
getState: function () { return S.server; },
// Test hooks: feed a slide as if ProPresenter sent it.
_processSlide: processSlide,
_navigateTo: navigateTo
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();