Files
bible-app/init.js
T

661 lines
33 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
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');
}
});