451 lines
22 KiB
JavaScript
451 lines
22 KiB
JavaScript
/*
|
||||
|
|
* 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();
|
|||
|
|
})();
|