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>
This commit is contained in:
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* 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 <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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user