Files
bible-app/propresenter-sync.js
T

451 lines
22 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* 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();
})();