Files
bible-app/init.js
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

661 lines
33 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.
import BibleApp from './app.js';
// Lightweight logger (console only; no on-screen banner)
const __bootBanner = { log: (...args) => console.log('[BOOT]', ...args) };
// Initialize the app when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
__bootBanner.log('DOMContentLoaded - init start');
window.__initError = null;
// Splash overlay setup (image file in Graphics/ can be swapped without code changes)
const splashOverlay = document.createElement('div');
splashOverlay.className = 'splash-overlay';
const splashImage = document.createElement('img');
splashImage.className = 'splash-image';
splashImage.alt = 'Splash';
// Resolve image with graceful fallback chain
// First check splash-config.json from content manager, then localStorage, then defaults
let configuredPath = '';
// Check localStorage first (for immediate loading)
try { configuredPath = (localStorage.getItem('splash.image') || '').trim(); } catch (_) { configuredPath = ''; }
const candidatePaths = [configuredPath, '/Notes/splash.png', '/Notes/splash.jpg', 'Graphics/splash.png', 'Graphics/splash.jpg', 'Graphics/FFTC_Podcast.png'].filter(Boolean);
let candidateIndex = 0;
const tryNextCandidate = () => {
if (candidateIndex >= candidatePaths.length) return;
const next = candidatePaths[candidateIndex++];
// Set src; onerror advances to next candidate
splashImage.src = next;
};
splashImage.onerror = () => { tryNextCandidate(); };
// Helper to apply a splash URL without flashing
const applySplash = (servedUrl, persistPath) => {
try {
const bust = servedUrl.includes('?') ? `${servedUrl}&t=${Date.now()}` : `${servedUrl}?t=${Date.now()}`;
const pre = new Image();
pre.onload = () => {
try { if (persistPath) localStorage.setItem('splash.image', persistPath); } catch(_) {}
splashImage.src = bust;
console.log('[SPLASH] Applied splash:', bust);
};
pre.onerror = () => {
console.warn('[SPLASH] Preload failed for', bust);
};
pre.src = bust;
} catch (e) {
console.error('[SPLASH] Failed to apply splash', e);
}
};
// Start loading immediately with localStorage/defaults, then update if API has newer default
tryNextCandidate();
// Function to refresh splash image from API (same pattern as notes)
const refreshSplashFromAPI = async () => {
try {
const resp = await fetch(`/api/splash/list?t=${Date.now()}`, { cache: 'no-store' });
if (resp.ok) {
const data = await resp.json();
console.log('[SPLASH] API response:', data);
// Find the actual file from the files list (like notes does)
let actualFilename = '';
if (data.currentDefault && data.files && Array.isArray(data.files)) {
// Verify the default file exists in the files list
const defaultFile = data.files.find(f => f.name === data.currentDefault);
if (defaultFile) {
actualFilename = defaultFile.name;
console.log('[SPLASH] Found default file in list:', actualFilename);
} else {
// Default doesn't match any file - use first file as fallback
if (data.files.length > 0) {
actualFilename = data.files[0].name;
console.log('[SPLASH] Default file not found, using first file:', actualFilename);
}
}
} else if (data.files && data.files.length > 0) {
// No default set, use first file
actualFilename = data.files[0].name;
console.log('[SPLASH] No default set, using first file:', actualFilename);
}
if (actualFilename) {
// Verify file exists using fileinfo API (like notes does)
let servedUrl = `/Notes/${encodeURIComponent(actualFilename)}`;
let verifiedFilename = actualFilename;
try {
const infoResp = await fetch(`/api/splash/fileinfo?file=${encodeURIComponent(actualFilename)}&t=${Date.now()}`, { cache: 'no-store' });
if (infoResp.ok) {
const info = await infoResp.json();
// Use the actual filename returned by fileinfo (handles case mismatches)
if (info && info.file) {
verifiedFilename = info.file;
}
const v = info && (info.mtimeMs || info.size || Date.now());
servedUrl = `/Notes/${encodeURIComponent(verifiedFilename)}?v=${v}`;
console.log('[SPLASH] File verified via fileinfo:', verifiedFilename, 'url:', servedUrl);
} else {
console.warn('[SPLASH] Fileinfo check failed, using filename anyway');
servedUrl = `/Notes/${encodeURIComponent(actualFilename)}?v=${Date.now()}`;
}
} catch (e) {
console.warn('[SPLASH] Fileinfo check error, using filename:', e);
servedUrl = `/Notes/${encodeURIComponent(actualFilename)}?v=${Date.now()}`;
}
const persistPath = `/Notes/${encodeURIComponent(verifiedFilename)}`;
applySplash(servedUrl, persistPath);
} else {
console.log('[SPLASH] No splash files available');
}
} else {
console.error('[SPLASH] API request failed:', resp.status, resp.statusText);
}
} catch (e) {
console.error('[SPLASH] Failed to load from API:', e);
}
};
// Update from API in background (will refresh if different)
refreshSplashFromAPI();
// Also refresh when page becomes visible again (e.g., returning from content manager)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
setTimeout(refreshSplashFromAPI, 500); // Small delay to ensure API is ready
}
});
splashOverlay.appendChild(splashImage);
document.body.appendChild(splashOverlay);
// Ensure overlay is always above any other UI layers on TVs
try { splashOverlay.style.zIndex = '1000000'; } catch (_) {}
const hideSplash = () => {
if (!splashOverlay.classList.contains('hidden')) {
splashOverlay.classList.add('hidden');
}
// Reset any multi-touch state so the next show/dismiss cycle is clean on TVs
try { if (typeof activeTouchPointers !== 'undefined' && activeTouchPointers.clear) activeTouchPointers.clear(); } catch (_) {}
// Safety: when splash is cleared, ensure Bible header gestures/watchdog are running
try {
const app = window.bibleApp;
if (app && typeof app.releaseGlobalTouchBlocks === 'function') {
app.releaseGlobalTouchBlocks();
}
const activeId = app && app.activeTabId;
const tab = app && app.tabs && app.tabs.find(t => t.id === activeId);
if (tab && (tab.mode === 'bible' || tab.mode === 'ppsync')) {
if (typeof app.installHeaderGestures === 'function') app.installHeaderGestures(tab);
if (typeof app.startHeaderGestureWatch === 'function') app.startHeaderGestureWatch(tab.id);
// Gentle retries after UI settles
setTimeout(() => { try { if (typeof app.installHeaderGestures === 'function') app.installHeaderGestures(tab); if (typeof app.startHeaderGestureWatch === 'function') app.startHeaderGestureWatch(tab.id); } catch (_) {} }, 150);
setTimeout(() => { try { if (typeof app.installHeaderGestures === 'function') app.installHeaderGestures(tab); if (typeof app.startHeaderGestureWatch === 'function') app.startHeaderGestureWatch(tab.id); } catch (_) {} }, 600);
}
} catch (_) {}
};
const showSplash = () => {
if (splashOverlay.classList.contains('hidden')) {
splashOverlay.classList.remove('hidden');
}
};
// Expose helpers so the main process can trigger reliable splash updates
// CRITICAL FIX: Track splash show time for watchdog recovery
const originalShowSplashLocal = showSplash;
/*
* Every splash appearance is recorded with WHY (localStorage 'splashHistory', last 20, shown in
* Settings › Splash Screen). The splash once came up while the pastor was using the TV and could
* not be reproduced; an unexplained splash on stage must never be unexplained again.
*/
window.__splashReason = window.__splashReason || 'unknown';
function __recordSplash(reason) {
try {
const raw = localStorage.getItem('splashHistory');
const list = raw ? JSON.parse(raw) : [];
list.push({ at: Date.now(), reason: String(reason || 'unknown') });
while (list.length > 20) list.shift();
localStorage.setItem('splashHistory', JSON.stringify(list));
} catch (_) {}
try { console.warn('[SPLASH] shown — reason: ' + reason); } catch (_) {}
}
/*
* Follower (booth) screen: the splash is MIRRORED from the stage and nothing else may touch it.
* follower-view.js passes FOLLOWER_MIRROR; every other caller — the idle timer, a ProPresenter slide,
* a remote action, a key — is refused and logged. In 1.4.29 the follower's own idle timer could still
* be armed by mouse movement on the booth Mac and by every ProPresenter slide, so the program feed
* went to a splash of its own (fitted to the content box) and came back on the next slide, over and
* over. Now the follower cannot show or hide a splash on its own at all.
*/
const FOLLOWER_MIRROR = '__follower-mirror';
const __isFollowerScreen = (() => { try { return !!window.__followerMode || /(^|[?&])follower=1(&|$)/.test(window.location.search); } catch (_) { return false; } })();
const wrappedShowSplash = (reason) => {
try {
if (__isFollowerScreen && reason !== FOLLOWER_MIRROR) {
try { console.warn('[SPLASH] follower: refused a local show — reason: ' + (reason || 'unknown')); } catch (_) {}
return;
}
const why = (reason === FOLLOWER_MIRROR) ? 'mirrored from the stage'
: ((typeof reason === 'string' && reason) ? reason : (window.__splashReason || 'unknown'));
window.__splashReason = 'unknown';
if (splashOverlay.classList.contains('hidden')) __recordSplash(why);
window._splashShowTime = Date.now();
originalShowSplashLocal();
try {
if (typeof window.sendCompanionStateFeedback === 'function') {
window.sendCompanionStateFeedback('splash_visible', { visible: true });
}
} catch (_) {}
} catch (err) {
console.warn('[SPLASH] Error showing splash:', err);
}
};
const originalHideSplashLocal = hideSplash;
const wrappedHideSplash = (reason) => {
try {
if (__isFollowerScreen && reason !== FOLLOWER_MIRROR) return; // mirrored only (see FOLLOWER_MIRROR)
window._splashShowTime = null;
originalHideSplashLocal();
try {
if (typeof window.sendCompanionStateFeedback === 'function') {
window.sendCompanionStateFeedback('splash_visible', { visible: false });
}
} catch (_) {}
} catch (err) {
console.warn('[SPLASH] Error hiding splash:', err);
}
};
try {
window.showSplash = (reason) => { try { wrappedShowSplash(typeof reason === 'string' ? reason : undefined); } catch(_) {} };
window.hideSplash = (reason) => { try { wrappedHideSplash(typeof reason === 'string' ? reason : undefined); } catch(_) {} };
window.updateSplashImage = (url) => {
try {
if (!url || typeof url !== 'string') return;
// Normalize to absolute path for server (leading slash)
const abs = url.startsWith('/') ? url : ('/' + url);
applySplash(abs, abs);
wrappedShowSplash('splash image changed (Content Manager)');
} catch(e) { console.error(e); }
};
} catch(_) {}
/*
* Say where the splash stands as soon as the app is up, without waiting for it to be shown or
* dismissed once. Companion's information line uses this as the safety net for "is the congregation
* looking at the splash or at the app", and a net that only arms after the first splash of the
* service is no net at all. Never from the follower: its splash only ever mirrors the stage's.
*/
if (!__isFollowerScreen) {
setTimeout(() => {
try {
if (typeof window.sendCompanionStateFeedback !== 'function') return;
const up = !!(splashOverlay && !splashOverlay.classList.contains('hidden'));
window.sendCompanionStateFeedback('splash_visible', { visible: up });
} catch (_) {}
}, 1500);
}
// Dismiss on tap or click (never on the follower: its splash is the stage's — see FOLLOWER_MIRROR)
const dismissSplash = () => { if (!__isFollowerScreen) hideSplash(); };
splashOverlay.addEventListener('click', dismissSplash);
// Use Pointer Events for robust touch-screen TVs (some devices don't emit TouchEvents on second show)
splashOverlay.addEventListener('pointerdown', () => { dismissSplash(); }, { passive: true });
splashOverlay.addEventListener('touchstart', (e) => { e.preventDefault(); dismissSplash(); }, { passive: false });
// Also allow ESC key to dismiss for keyboards/air mice
// CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug
window.addEventListener('keydown', (e) => {
try {
// Defensive check: ensure event object is valid before accessing properties
if (!e || typeof e !== 'object') return;
// Check if key property exists and is a valid string before comparing
if (e.key === 'Escape' || e.key === 'Esc') {
dismissSplash();
}
} catch (err) {
// Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect)
console.warn('[SPLASH] Keyboard event handler error (likely keyboard disconnect):', err);
// Ensure splash doesn't get stuck showing due to errors
try { hideSplash(); } catch (_) {}
}
}, { capture: true });
/*
* Five-finger touch shows the splash. On by default, as it always was; Settings › Splash Screen can
* turn it off if a resting hand keeps bringing the splash up. Never on the follower (its input is
* blocked, and wrappedShowSplash refuses local shows there anyway).
*/
const fiveFingerSplashEnabled = () => { try { return localStorage.getItem('fiveFingerSplash') !== '0'; } catch (_) { return true; } };
document.addEventListener('touchstart', (e) => {
if (e.touches && e.touches.length >= 5 && fiveFingerSplashEnabled()) {
e.preventDefault();
wrappedShowSplash('five-finger touch');
}
}, { passive: false, capture: true });
// More reliable multi-touch detection using Pointer Events
const activeTouchPointers = new Set();
const onPointerDown = (e) => {
if (e.pointerType === 'touch') {
activeTouchPointers.add(e.pointerId);
if (activeTouchPointers.size >= 5 && fiveFingerSplashEnabled()) {
try { e.preventDefault(); } catch(_) {}
wrappedShowSplash('five-finger touch');
}
}
};
const onPointerUpOrCancel = (e) => {
if (e.pointerType === 'touch') {
activeTouchPointers.delete(e.pointerId);
}
};
window.addEventListener('pointerdown', onPointerDown, { capture: true, passive: false });
window.addEventListener('pointerup', onPointerUpOrCancel, { capture: true });
window.addEventListener('pointercancel', onPointerUpOrCancel, { capture: true });
document.addEventListener('visibilitychange', () => { if (document.hidden) activeTouchPointers.clear(); }, { capture: true });
// Global error logger to surface silent failures
window.addEventListener('error', (e) => {
try {
__bootBanner.log('GLOBAL ERROR: ' + (e.message || e));
console.error('[GLOBAL ERROR]', e.message || e, e.error || '');
} catch (_) {}
});
window.addEventListener('unhandledrejection', (e) => {
try {
__bootBanner.log('UNHANDLED REJECTION: ' + (e.reason && e.reason.message ? e.reason.message : e.reason || e));
console.error('[UNHANDLED REJECTION]', e.reason || e);
} catch (_) {}
});
// Create the global BibleApp instance
try {
window.bibleApp = new BibleApp();
__bootBanner.log('BibleApp created OK');
console.log('[INIT] BibleApp created', window.bibleApp);
} catch (e) {
window.__initError = e;
__bootBanner.log('BibleApp init failed: ' + (e && e.message ? e.message : e));
console.error('[INIT] Failed to create BibleApp', e);
}
// CRITICAL FIX: Ensure media interactions are initialized after a delay
// This ensures that if the user goes directly to a media tab, it will work
setTimeout(() => {
if (window.ensureMediaInteractions) {
window.ensureMediaInteractions();
console.log('[DEBUG] Global media interactions initialization complete');
}
}, 1000);
// SAFETY NET: One-time additional check after 3 seconds for edge cases
setTimeout(() => {
const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp.activeTabId);
if (activeTab && activeTab.mode === 'media' && window.ensureMediaInteractions) {
window.ensureMediaInteractions();
console.log('[SAFETY] Final media interactions check completed');
}
}, 3000);
// CRITICAL FIX: Splash screen recovery watchdog - monitors splash state (no auto-hide)
// The splash screen will stay visible until user touches/clicks the screen
let splashWatchdogInterval = null;
const startSplashWatchdog = () => {
// Clear any existing watchdog
if (splashWatchdogInterval) {
clearInterval(splashWatchdogInterval);
}
// Monitor splash state (for debugging/logging purposes only - no auto-hide)
splashWatchdogInterval = setInterval(() => {
try {
const splashOverlay = document.querySelector('.splash-overlay');
if (splashOverlay && !splashOverlay.classList.contains('hidden')) {
// Track show time for logging, but don't auto-hide
const now = Date.now();
if (!window._splashShowTime) {
window._splashShowTime = now;
}
} else {
// Splash is hidden - reset timer
window._splashShowTime = null;
}
} catch (err) {
console.warn('[SPLASH WATCHDOG] Error in watchdog:', err);
}
}, 2000);
};
// Start the watchdog after a short delay
setTimeout(startSplashWatchdog, 5000);
/*
* Inactivity splash — Settings › Splash Screen: "go to the splash after N seconds without a touch".
* Default 60 s (the behaviour the TV always had), 0 = never. Re-read every time the timer is
* (re)armed, so a change in Settings applies at once. Never armed on the follower screen: nobody
* touches that machine, so its timer would always expire and drop a splash over the program feed
* (and 1.4.29's arms from mouse movement / remote actions / ProPresenter slides did exactly that).
*/
let __idleTimerId = null;
const IDLE_SPLASH_DEFAULT_SEC = 60;
function __idleSplashSec() {
try {
const raw = localStorage.getItem('idleSplashSeconds');
if (raw === null || raw === '') return IDLE_SPLASH_DEFAULT_SEC;
const sec = parseFloat(raw);
return (isFinite(sec) && sec >= 0) ? sec : IDLE_SPLASH_DEFAULT_SEC; // 0 = never
} catch (_) { return IDLE_SPLASH_DEFAULT_SEC; }
}
function __resetIdleTimer() {
try { if (__idleTimerId) clearTimeout(__idleTimerId); } catch (_) {}
__idleTimerId = null;
if (__isFollowerScreen) return;
const sec = __idleSplashSec();
if (!(sec > 0)) return;
__idleTimerId = setTimeout(() => {
try { wrappedShowSplash('idle for ' + sec + ' s (Settings › Splash Screen)'); } catch (_) {}
}, Math.round(sec * 1000));
}
// Settings is another window on the same origin: its writes arrive here as storage events.
window.addEventListener('storage', (ev) => { try { if (ev && ev.key === 'idleSplashSeconds') __resetIdleTimer(); } catch (_) {} });
/*
* Exposed for the phone remote: its scrolls/strokes arrive as IPC actions, not DOM events, so the
* listeners below never see them — the splash kept popping up MID-TEACHING while the operator was
* actively driving from the phone. Every remote action counts as user activity (app.js calls this).
*/
try { window.__resetIdleTimer = __resetIdleTimer; } catch (_) {}
// Reset timer on common user interactions
['pointerdown','pointermove','wheel','keydown','mousedown','mousemove','touchstart'].forEach((evt) => {
window.addEventListener(evt, __resetIdleTimer, { capture: true, passive: true });
});
// Reset when app becomes visible again
document.addEventListener('visibilitychange', () => {
if (!document.hidden) __resetIdleTimer();
}, { capture: true });
/*
* The booth follower (?follower=1) must NEVER run this. Nobody touches that machine, so its idle
* timer would always expire and drop the splash over the program feed going to the switcher —
* and, with no input to reset it, leave it there for the rest of the service. The follower shows
* the splash only when the STAGE does, mirrored through follower-view.js.
*/
// Start the idle timer. __resetIdleTimer guards itself on the follower, so the re-arms from mouse
// movement, remote actions and ProPresenter slides are harmless there too.
__resetIdleTimer();
// Track splash show time on user interaction (for logging purposes)
const resetSplashTimer = () => {
try {
// Only reset if splash is currently showing
const splashOverlay = document.querySelector('.splash-overlay');
if (splashOverlay && !splashOverlay.classList.contains('hidden')) {
window._splashShowTime = Date.now();
}
} catch (_) {}
};
['pointerdown','pointermove','wheel','keydown','mousedown','mousemove','touchstart','click'].forEach((evt) => {
window.addEventListener(evt, resetSplashTimer, { capture: true, passive: true });
});
// Listen for splash updates from main process (robust channel)
try {
if (window.electron && window.electron.ipcRenderer && typeof window.electron.ipcRenderer.on === 'function') {
window.electron.ipcRenderer.on('splash:update', (_evt, url) => {
try { if (typeof window.updateSplashImage === 'function') window.updateSplashImage(url); } catch(_) {}
});
}
} catch(_) {}
// Listen for content manager updates via window messages (when content manager sets new defaults)
window.addEventListener('message', (event) => {
try {
if (event.data && event.data.type === 'content-update') {
const { contentType, filename } = event.data;
if (contentType === 'splash') {
// Refresh splash image immediately using the same logic as refreshSplashFromAPI
console.log('[SPLASH] Content manager update received - refreshing splash');
if (filename) {
const servedUrl = `/Notes/${encodeURIComponent(filename)}`;
applySplash(servedUrl, servedUrl.startsWith('/') ? servedUrl : `/${servedUrl}`);
} else {
refreshSplashFromAPI();
}
} else if (contentType === 'announcements') {
// Refresh announcements tab if it's currently active
setTimeout(() => {
try {
if (window.bibleApp) {
const activeTab = window.bibleApp.tabs?.find(t => t.id === window.bibleApp.activeTabId);
if (activeTab && activeTab.mode === 'announcements') {
console.log('[ANNOUNCEMENTS] Content manager update - refreshing tab:', activeTab.id);
// Force reload by re-rendering the tab content
window.bibleApp.renderTabContent(activeTab);
}
}
} catch (e) {
console.error('[ANNOUNCEMENTS] Failed to refresh from content manager:', e);
}
}, 200);
} else if (contentType === 'graphics') {
// Refresh graphics tab if it's currently active
setTimeout(() => {
try {
if (window.bibleApp) {
const activeTab = window.bibleApp.tabs?.find(t => t.id === window.bibleApp.activeTabId);
if (activeTab && activeTab.mode === 'graphics') {
console.log('[GRAPHICS] Content manager update - refreshing tab:', activeTab.id);
window.bibleApp.renderTabContent(activeTab);
}
}
} catch (e) {
console.error('[GRAPHICS] Failed to refresh from content manager:', e);
}
}, 200);
}
}
} catch (_) {}
});
// Touch Menu - Hidden menu for touchscreen TVs
const touchMenuBtn = document.getElementById('touchMenuBtn');
const touchMenuOverlay = document.getElementById('touchMenuOverlay');
const touchMenuClose = touchMenuOverlay?.querySelector('.touch-menu-close');
const touchMenuSettings = document.getElementById('touchMenuSettings');
const touchMenuContentManager = document.getElementById('touchMenuContentManager');
if (touchMenuBtn && touchMenuOverlay) {
const openTouchMenu = () => {
touchMenuOverlay.classList.remove('hidden');
console.log('[TOUCH MENU] Opened');
};
const closeTouchMenu = () => {
touchMenuOverlay.classList.add('hidden');
console.log('[TOUCH MENU] Closed');
};
// Open menu on button click/tap
// Prevent tool selection and tool rail auto-close
touchMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openTouchMenu();
});
touchMenuBtn.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
openTouchMenu();
}, { passive: false });
// Touch fallback for TVs
touchMenuBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
openTouchMenu();
}, { passive: false });
// Close menu on close button
if (touchMenuClose) {
touchMenuClose.addEventListener('click', (e) => {
e.stopPropagation();
closeTouchMenu();
});
// pointerUP, not pointerdown: closing while the finger is still down made the overlay stop
// hit-testing mid-tap, so the click Chrome synthesises on lift landed on whatever was behind
// the X — it opened the book underneath. Kept as the fallback for TVs that emit no
// TouchEvents and no click; overlay-close-guard.js is the belt to this braces.
touchMenuClose.addEventListener('pointerup', (e) => {
e.stopPropagation();
closeTouchMenu();
}, { passive: true });
}
// Close menu when clicking outside (on overlay background)
touchMenuOverlay.addEventListener('click', (e) => {
if (e.target === touchMenuOverlay) {
closeTouchMenu();
}
});
// Navigate to Settings
if (touchMenuSettings) {
touchMenuSettings.addEventListener('click', () => {
try {
// Use relative URL since we're on the same server
window.location.href = '/settings.html';
} catch (e) {
console.error('[TOUCH MENU] Failed to open settings:', e);
}
});
}
// Navigate to Content Manager
if (touchMenuContentManager) {
touchMenuContentManager.addEventListener('click', () => {
try {
// Use relative URL since we're on the same server
window.location.href = '/content-manager.html';
} catch (e) {
console.error('[TOUCH MENU] Failed to open content manager:', e);
}
});
}
// Show About dialog
const touchMenuAbout = document.getElementById('touchMenuAbout');
if (touchMenuAbout) {
touchMenuAbout.addEventListener('click', async () => {
try {
closeTouchMenu(); // Close menu first
if (window.electron && window.electron.ipcRenderer) {
await window.electron.ipcRenderer.invoke('show-about');
} else {
// Opened in a plain browser rather than the app window: ask the server for
// the running version instead of scraping app.js's header comment.
let version = '';
try {
const r = await fetch('/api/update/status');
if (r.ok) {
const j = await r.json();
version = (j && j.version) ? String(j.version) : '';
}
} catch (_) {}
alert('Bible App' + (version ? ' ' + version : ''));
}
} catch (e) {
console.error('[TOUCH MENU] Failed to show about:', e);
alert('Bible App');
}
});
}
// Exit application
const touchMenuExit = document.getElementById('touchMenuExit');
if (touchMenuExit) {
touchMenuExit.addEventListener('click', async () => {
try {
closeTouchMenu(); // Close menu first
if (window.electron && window.electron.ipcRenderer) {
await window.electron.ipcRenderer.invoke('close-app');
} else {
// Fallback: try to close window
if (window.close) {
window.close();
}
}
} catch (e) {
console.error('[TOUCH MENU] Failed to exit:', e);
}
});
}
// Close menu on Escape key (for keyboard/air mouse users)
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !touchMenuOverlay.classList.contains('hidden')) {
closeTouchMenu();
}
}, { capture: true });
console.log('[TOUCH MENU] Initialized');
}
});