661 lines
33 KiB
JavaScript
661 lines
33 KiB
JavaScript
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');
|
|||
|
|
}
|
|||
|
|
});
|