Files
Rosary/assets/js/presenter.js
T
pguzman 663fde3909 Initial commit — Rosary Presenter App
Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:44:08 -07:00

669 lines
24 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.
/**
* 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 = '&#128264;'; // 🔊
btnAudio.title = 'Audio on — click to mute';
} else {
btnAudio.innerHTML = '&#128263;'; // 🔇
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 <br> 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]);
}
});
}
})();