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'); } });