/* * 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(); })();