/** * assets/js/presenter.js (v11) * * Controls slide navigation and DOM updates for the presentation view. * Depends on: * - SLIDES (global, JSON array from PHP) * - SESSION_ID (global, integer session ID) * - BACK_URL (global, URL to return to on exit) * - RosaryRing (global, from rosary.js) */ (function () { 'use strict'; if (!window.SLIDES || !SLIDES.length) { console.error('No slides data found.'); return; } var currentIndex = 0; // ----------------------------------------------------------------------- // Slide expansion — pre-split long prayers into "Part 1 / 2" / "Part 2 / 2" // so text stays large and readable on Zoom / projector rather than shrinking // to illegibility. Prayers with more than SPLIT_THRESHOLD combined lines are // automatically split at the nearest paragraph break. // ----------------------------------------------------------------------- var SPLIT_THRESHOLD = 10; function countLines(text) { if (!text || !text.trim()) return 0; return text.split('\n').length; } function splitText(text) { // Prefer splitting at paragraph breaks (\n\n) var paras = text.split('\n\n'); if (paras.length >= 2) { var mid = Math.ceil(paras.length / 2); var p1 = paras.slice(0, mid).join('\n\n').trim(); var p2 = paras.slice(mid).join('\n\n').trim(); if (p1 && p2) return [p1, p2]; } // Fall back: split at line midpoint var lines = text.split('\n'); var mid = Math.ceil(lines.length / 2); return [lines.slice(0, mid).join('\n').trim(), lines.slice(mid).join('\n').trim()]; } function expandSlides(rawSlides) { var result = []; for (var i = 0; i < rawSlides.length; i++) { var s = rawSlides[i]; var leaderLines = countLines(s.leader); var allLines = countLines(s.all); if (leaderLines + allLines <= SPLIT_THRESHOLD) { result.push(s); continue; } // Decide which field to split (the longer one) var field = (allLines >= leaderLines) ? 'all' : 'leader'; var halves = splitText(s[field]); if (!halves[0] || !halves[1]) { result.push(s); continue; } // safety var p1 = Object.assign({}, s); var p2 = Object.assign({}, s); p1._partNum = 1; p1._partTotal = 2; p2._partNum = 2; p2._partTotal = 2; p2.bead_index = null; // bead was "prayed" in part 1 if (field === 'all') { p1.all = halves[0]; p2.leader = ''; p2.all = halves[1]; } else { p1.leader = halves[0]; p1.all = ''; p2.leader = halves[1]; } result.push(p1, p2); } return result; } var slides = expandSlides(SLIDES); var total = slides.length; // localStorage key for resume position var RESUME_KEY = 'rosary_pos_' + (window.SESSION_ID || '0'); // Pre-compute repeat-run info for every (possibly expanded) slide. // Group consecutive slides by prayer content (leader + all text), not title — // so opening Hail Marys "in Faith / Hope / Charity" and decade Hail Marys all // detect as runs even though their titles differ. var slideRunInfo = (function () { var info = new Array(total).fill(null); var i = 0; while (i < total) { var s = slides[i]; var key = s.type + '||' + (s.leader || '') + '||' + (s.all || ''); var start = i; while (i < total) { var si = slides[i]; var ki = si.type + '||' + (si.leader || '') + '||' + (si.all || ''); if (ki !== key) break; i++; } var len = i - start; if (len > 1) { for (var j = start; j < i; j++) { info[j] = { pos: j - start + 1, total: len }; } } } return info; })(); // Map bead index → first slide index that uses that bead. // Used by bead click navigation so clicking a bead jumps to the right slide. var beadToSlide = {}; (function () { for (var i = 0; i < total; i++) { var bi = slides[i].bead_index; if (bi !== null && bi !== undefined && !(bi in beadToSlide)) { beadToSlide[bi] = i; } } })(); // Scan slides 0..upToIndex and return the highest non-null bead_index seen. // Used so "between-bead" slides (Glory Be, Fatima, litanies) keep prayed // beads red instead of resetting everything to gray. function getLastPrayedBead(upToIndex) { var last = null; for (var i = 0; i <= upToIndex; i++) { var bi = slides[i] ? slides[i].bead_index : null; if (bi !== null && bi !== undefined) { last = bi; } } return last; } // DOM refs var coverSlide = document.getElementById('cover-slide'); var prayerSlide = document.getElementById('prayer-slide'); var mysterySlide = document.getElementById('mystery-slide'); var litanySlide = document.getElementById('litany-slide'); var closingSlide = document.getElementById('closing-slide'); var btnPrev = document.getElementById('btn-prev'); var btnNext = document.getElementById('btn-next'); var btnExit = document.getElementById('btn-exit'); var btnAudio = document.getElementById('btn-audio'); var slideCounter = document.getElementById('slide-counter'); var resumeToast = document.getElementById('resume-toast'); // -------------------------------------------------------------------------- // Audio // AUDIO_MANIFEST: {key: ext, ...} — provided by present.php // AUDIO_BASE_URL: '/uploads/audio/' // -------------------------------------------------------------------------- var AUDIO_PREF_KEY = 'rosary_audio'; // localStorage — global pref, not per-session var audioEnabled = false; var currentAudio = null; var hasAudioFiles = window.AUDIO_MANIFEST && Object.keys(AUDIO_MANIFEST).length > 0; if (hasAudioFiles) { var stored = localStorage.getItem(AUDIO_PREF_KEY); // Default ON when audio is available; user can mute audioEnabled = (stored === null) ? true : (stored === 'on'); } function updateAudioBtn() { if (!btnAudio) return; if (audioEnabled) { btnAudio.innerHTML = '🔈'; // 🔊 btnAudio.title = 'Audio on — click to mute'; } else { btnAudio.innerHTML = '🔇'; // 🔇 btnAudio.title = 'Audio off — click to enable'; } } function stopAudio() { if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; currentAudio = null; } } // Map a slide's id to a canonical audio key. // Many slides share one recording (e.g. all 55 Hail Marys → 'hail_mary'). function getAudioKey(slide) { if (!slide) return null; var id = slide.id || ''; if (!id || id === 'cover') return null; // Common prayers — many slides → one key if (id === 'sign_of_cross' || id === 'dm_sign_of_cross') return 'sign_of_cross'; if (id === 'apostles_creed' || id === 'dm_apostles_creed') return 'apostles_creed'; if (id.indexOf('our_father') === 0 || id === 'dm_our_father_opening') return 'our_father'; if (id.indexOf('hail_mary') === 0 || id === 'dm_hail_mary_opening') return 'hail_mary'; if (id.indexOf('glory_be') === 0) return 'glory_be'; if (id.indexOf('fatima_') === 0) return 'fatima_prayer'; // Mysteries — unique per mystery if (id.indexOf('mystery_') === 0) return id; // Hail Holy Queen — both slides share one audio if (id.indexOf('hail_holy_queen') === 0) return 'hail_holy_queen'; // Rosary closing prayer + generic closing if (id === 'rosary_closing_prayer') return 'rosary_closing_prayer'; if (id === 'closing') return 'closing'; // Litany of Passion — unique per entry if (id.indexOf('litany_passion_') === 0) return id; // Novena deceased day prayers — unique per day if (id.indexOf('novena_day_') === 0) return id; // Litany for Departed — unique per entry if (id.indexOf('litany_departed_') === 0) return id; // Divine Mercy if (id === 'dm_opening') return 'dm_opening'; if (id.indexOf('dm_blood_water') === 0) return 'dm_blood_water'; if (id.indexOf('dm_intention_day_') === 0) return id; if (id.indexOf('dm_prayer_day_') === 0) return id; if (id.indexOf('dm_eternal_father') === 0) return 'dm_eternal_father'; if (id.indexOf('dm_for_sake') === 0) return 'dm_for_sake'; if (id.indexOf('dm_holy_god') === 0) return 'dm_holy_god'; return id; // fallback: use slide ID directly } function playSlideAudio(slide) { stopAudio(); if (!audioEnabled || !hasAudioFiles) return; var key = getAudioKey(slide); if (!key || !AUDIO_MANIFEST[key]) return; var audio = new Audio(window.AUDIO_BASE_URL + key + '.' + AUDIO_MANIFEST[key]); audio.play().catch(function () { /* silently skip — file may be absent */ }); currentAudio = audio; } if (btnAudio) { btnAudio.addEventListener('click', function () { audioEnabled = !audioEnabled; try { localStorage.setItem(AUDIO_PREF_KEY, audioEnabled ? 'on' : 'off'); } catch (e) {} updateAudioBtn(); if (!audioEnabled) stopAudio(); }); } updateAudioBtn(); // Hide all slide panels function hideAll() { coverSlide.style.display = 'none'; prayerSlide.style.display = 'none'; mysterySlide.style.display = 'none'; litanySlide.style.display = 'none'; closingSlide.style.display = 'none'; } // Helpers function setText(id, value) { var el = document.getElementById(id); if (el) el.textContent = value || ''; } function setHTML(id, value) { var el = document.getElementById(id); if (el) el.innerHTML = value || ''; } function showEl(id) { var el = document.getElementById(id); if (el) el.style.display = ''; } function hideEl(id) { var el = document.getElementById(id); if (el) el.style.display = 'none'; } // Convert newlines to
for display, keeping pre-line behavior function nl(text) { if (!text) return ''; return text.replace(/\n/g, '\n'); // already handled by white-space: pre-line } // -------------------------------------------------------------------------- // Render a slide // -------------------------------------------------------------------------- function render(index) { hideAll(); var slide = slides[index]; if (!slide) return; // Trigger CSS fade-in by re-inserting / touching the element switch (slide.type) { case 'cover': renderCover(slide); coverSlide.style.display = ''; break; case 'mystery': renderMystery(slide); mysterySlide.style.display = ''; break; case 'litany': renderLitany(slide); litanySlide.style.display = ''; break; case 'closing': renderClosing(slide); closingSlide.style.display = ''; break; default: // 'prayer' renderPrayer(slide); prayerSlide.style.display = ''; break; } // Update counter (cover is slide 0, not counted in "Prayer X of Y") updateCounter(index); // Show repeat badge when the same content repeats (e.g. Agnus Dei × 3) updateRepeatBadge(index); // Shrink text so the slide fits on screen without scrolling fitTextToScreen(); // Update rosary bead ring — pass both the current bead and the last prayed bead // so "between-bead" slides (Glory Be, Fatima, litanies) keep the ring colored. if (typeof RosaryRing !== 'undefined') { var currentBead = (slide.bead_index !== undefined) ? slide.bead_index : null; var lastBead = getLastPrayedBead(index); RosaryRing.update(currentBead, lastBead); } // Scroll slide content back to top on each navigation var content = document.getElementById('slide-content'); if (content) content.scrollTop = 0; // Update nav buttons btnPrev.disabled = (index === 0); btnNext.disabled = (index === total - 1); } function renderCover(slide) { setText('cover-title', slide.title); setText('cover-body', slide.all); var photoWrap = document.getElementById('cover-photo-wrap'); var photoImg = document.getElementById('cover-photo'); if (slide.photo_path && photoImg) { photoImg.src = slide.photo_path; photoImg.style.display = ''; photoWrap.classList.remove('no-photo'); } else { photoWrap.classList.add('no-photo'); } } function renderPrayer(slide) { setText('slide-title', slide.title); var hasLeader = !!(slide.leader && slide.leader.trim()); var hasAll = !!(slide.all && slide.all.trim()); if (hasLeader) { showEl('leader-wrap'); setText('leader-text', slide.leader); } else { hideEl('leader-wrap'); } if (hasAll) { showEl('all-wrap'); setText('all-text', slide.all); } else { hideEl('all-wrap'); } // Bump font size when only one section is present var slideText = document.getElementById('slide-text'); if (slideText) { if (hasLeader !== hasAll) { slideText.classList.add('single-section'); } else { slideText.classList.remove('single-section'); } } } function renderMystery(slide) { // title is like "The Third Sorrowful Mystery" // leader is the name of the mystery // all is the fruit/intention setText('mystery-number', slide.title); setText('mystery-title', slide.leader); setText('mystery-fruit', slide.all); } function renderLitany(slide) { setText('litany-title', slide.title); var hasLeader = !!(slide.leader && slide.leader.trim()); var hasAll = !!(slide.all && slide.all.trim()); if (hasLeader) { showEl('litany-leader-wrap'); setText('litany-leader', slide.leader); } else { hideEl('litany-leader-wrap'); } if (hasAll) { showEl('litany-all-wrap'); setText('litany-all', slide.all); } else { hideEl('litany-all-wrap'); } } function renderClosing(slide) { setText('closing-title', slide.title); setText('closing-subtitle', slide.subtitle || ''); setText('closing-body', slide.all); var photoWrap = document.getElementById('closing-photo-wrap'); var photoImg = document.getElementById('closing-photo'); if (slide.photo_path && photoImg) { photoImg.src = slide.photo_path; photoWrap.style.display = ''; } else if (photoWrap) { photoWrap.style.display = 'none'; } } // -------------------------------------------------------------------------- // fitTextToScreen — shrink prayer/litany text until the slide fits on screen // without a scrollbar. Uses binary search over pixel font-size. // Called after every render so long prayers (e.g. Day 5 novena, Concluding // Prayer) automatically scale down rather than requiring a scroll. // -------------------------------------------------------------------------- var FIT_TEXT_ELS = '#leader-text, #all-text, #litany-leader, #litany-all, #closing-body'; var FIT_MIN_PX = 22; // floor — stay readable for Zoom / projector audiences function fitTextToScreen() { var container = document.getElementById('slide-content'); if (!container) return; var textEls = container.querySelectorAll(FIT_TEXT_ELS); // Reset any previous override so we measure the natural (CSS) size for (var k = 0; k < textEls.length; k++) { textEls[k].style.fontSize = ''; } // Already fits — nothing to do if (container.scrollHeight <= container.clientHeight + 2) return; if (!textEls.length) return; // no adjustable text (cover, mystery, etc.) // Upper bound = current computed size; lower bound = readable minimum var naturalPx = parseFloat(window.getComputedStyle(textEls[0]).fontSize); var lo = FIT_MIN_PX; var hi = naturalPx; // Binary search: 20 iterations converges to < 0.1 px accuracy for (var iter = 0; iter < 20; iter++) { var mid = (lo + hi) / 2; for (var k = 0; k < textEls.length; k++) { textEls[k].style.fontSize = mid + 'px'; } if (container.scrollHeight <= container.clientHeight + 2) { lo = mid; // fits — try a touch larger } else { hi = mid; // overflows — try smaller } } // Lock in the converged size for (var k = 0; k < textEls.length; k++) { textEls[k].style.fontSize = lo + 'px'; } } function updateCounter(index) { var prayerTotal = total - 1; // exclude cover if (index === 0) { slideCounter.textContent = ''; } else { slideCounter.textContent = 'Prayer ' + index + ' of ' + prayerTotal; } } function updateRepeatBadge(index) { // Hide every badge first (avoid NodeList.forEach for compatibility) var allBadges = document.querySelectorAll('.repeat-badge'); for (var k = 0; k < allBadges.length; k++) { allBadges[k].style.display = 'none'; allBadges[k].textContent = ''; } var slide = slides[index]; // Part badge takes priority (long prayer split across slides) var badgeText = ''; if (slide._partNum) { badgeText = 'Part ' + slide._partNum + ' / ' + slide._partTotal; } else { var run = slideRunInfo[index]; if (run) badgeText = run.pos + ' of ' + run.total; } if (!badgeText) return; // Show the badge inside the currently-visible slide panel var panelId = (slide.type === 'litany') ? 'litany-slide' : 'prayer-slide'; var panel = document.getElementById(panelId); var badge = panel ? panel.querySelector('.repeat-badge') : null; if (badge) { badge.textContent = badgeText; badge.style.display = ''; } } // -------------------------------------------------------------------------- // Navigation // -------------------------------------------------------------------------- function goTo(index) { if (index < 0 || index >= total) return; currentIndex = index; render(currentIndex); // Play audio for this slide (stops previous audio automatically) playSlideAudio(slides[index]); // Persist position so the user can resume after exiting. // Clear it when they reach the closing slide (rosary complete). try { if (index > 0 && index < total - 1) { localStorage.setItem(RESUME_KEY, index); } else { localStorage.removeItem(RESUME_KEY); } } catch (e) { /* storage unavailable */ } } function prev() { goTo(currentIndex - 1); } function next() { goTo(currentIndex + 1); } btnPrev.addEventListener('click', prev); btnNext.addEventListener('click', next); if (btnExit) { btnExit.addEventListener('click', function () { // Position already saved by goTo; navigate back window.location.href = window.BACK_URL || '/'; }); } // Keyboard document.addEventListener('keydown', function (e) { switch (e.key) { case 'ArrowRight': case 'ArrowDown': case ' ': e.preventDefault(); next(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); prev(); break; case 'f': case 'F': toggleFullscreen(); break; } }); // Touch swipe var touchStartX = null; var touchStartY = null; document.addEventListener('touchstart', function (e) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }, { passive: true }); document.addEventListener('touchend', function (e) { if (touchStartX === null) return; var dx = e.changedTouches[0].clientX - touchStartX; var dy = e.changedTouches[0].clientY - touchStartY; // Only respond to mostly-horizontal swipes if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) { if (dx < 0) next(); else prev(); } touchStartX = null; touchStartY = null; }, { passive: true }); // Fullscreen function toggleFullscreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(function () {}); } else { document.exitFullscreen().catch(function () {}); } } // Re-fit text when the window is resized (e.g. projector moved to a // different monitor, fullscreen toggled, or phone rotated). var resizeTimer = null; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { render(currentIndex); }, 150); }); // -------------------------------------------------------------------------- // Initialize // -------------------------------------------------------------------------- // Check for a saved resume position var resumeIndex = 0; try { var saved = localStorage.getItem(RESUME_KEY); if (saved !== null) { var parsed = parseInt(saved, 10); if (!isNaN(parsed) && parsed > 0 && parsed < total - 1) { resumeIndex = parsed; } } } catch (e) { /* storage unavailable */ } render(resumeIndex); currentIndex = resumeIndex; // Show fade-out toast if resuming mid-prayer if (resumeIndex > 0 && resumeToast) { resumeToast.textContent = 'Resumed at prayer ' + resumeIndex + ' of ' + (total - 1); resumeToast.classList.add('visible'); setTimeout(function () { resumeToast.classList.remove('visible'); }, 2800); } // Wire bead clicks: jump to the first slide that uses that bead if (typeof RosaryRing !== 'undefined' && RosaryRing.onBeadClick) { RosaryRing.onBeadClick(function (beadIndex) { if (beadIndex in beadToSlide) { goTo(beadToSlide[beadIndex]); } }); } })();