// ============================================================ // App: Entry point — imports all modules, registers window globals, // and orchestrates initialization (replaces main.js) // ============================================================ // Layer 0: Core state & utilities import { cacheDom, dom, registerUpdateCallbacks, initLocale, fetchVersion, formatTime, setupIconPreview, isUserAdjustingVolume, setIsUserAdjustingVolume, volumeUpdateTimer, setVolumeUpdateTimer, currentDuration, currentPosition, setVolume, seek, togglePlayPause, nextTrack, previousTrack, toggleMute, VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS, changeLocale, t, setAuthRequired, } from './core.js'; // Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI) import { activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible, initTheme, toggleTheme, initAccentColor, applyAccentColor, renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor, visualizerEnabled, visualizerAvailable, setVisualizerEnabled, checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode, loadAudioDevices, onAudioDeviceChanged, setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation, togglePlayerFullscreen, initPlayerFullscreen, } from './player.js'; // Layer 2: WebSocket import { connectWebSocket, showAuthForm, authenticate, clearToken, manualReconnect, updateConnectionStatus, } from './websocket.js'; // Layer 3: Features import { loadScripts, loadScriptsTable, displayQuickAccess, showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript, deleteScriptConfirm, executeScriptDebug, executeCallbackDebug, closeExecutionDialog, scriptFormDirty, setScriptFormDirty, addParameterRow, closeScriptParamsDialog, submitScriptWithParams, } from './scripts.js'; import { loadCallbacksTable, showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog, saveCallback, deleteCallbackConfirm, callbackFormDirty, setCallbackFormDirty, } from './callbacks.js'; import { loadMediaFolders, initBrowserToolbar, thumbnailCache, setViewMode, refreshBrowser, playAllFolder, previousPage, nextPage, goToPage, onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, downloadFile, closeFolderDialog, saveFolder, showManageFoldersDialog, showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm, } from './browser.js'; import { loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, toggleDisplayPower, loadHeaderLinks, loadLinksTable, showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, linkFormDirty, setLinkFormDirty, } from './links.js'; import { toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors, } from './background.js'; // ============================================================ // Register late-bound callbacks for core's updateAllText() // ============================================================ registerUpdateCallbacks({ updatePlaybackState, updateConnectionStatus, loadScriptsTable, loadCallbacksTable, loadLinksTable, displayQuickAccess, renderAccentSwatches, }); // ============================================================ // Register all functions on window for HTML onclick handlers // ============================================================ Object.assign(window, { // Player controls togglePlayPause, nextTrack, previousTrack, toggleMute, seek, // Tabs switchTab, // Theme & accent toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor, // Visualizer (vinyl spin is structural CSS — no toggle) toggleVisualizer, // Background toggleDynamicBackground, // Fullscreen togglePlayerFullscreen, // Auth authenticate, clearToken, manualReconnect, // Locale changeLocale, // Scripts showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript, deleteScriptConfirm, executeScriptDebug, executeCallbackDebug, closeExecutionDialog, addParameterRow, closeScriptParamsDialog, submitScriptWithParams, // Callbacks showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog, saveCallback, deleteCallbackConfirm, // Browser setViewMode, refreshBrowser, playAllFolder, previousPage, nextPage, goToPage, onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, downloadFile, closeFolderDialog, saveFolder, showManageFoldersDialog, showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm, // Links showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, // Display loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, toggleDisplayPower, // Audio device onAudioDeviceChanged, }); // ============================================================ // Initialization (DOMContentLoaded) // ============================================================ // Prevent .showModal() from auto-focusing the first input field. // On touch devices this pops up the on-screen keyboard, which is confusing // when the user just opened a dialog. Force focus onto the dialog itself. const _origShowModal = HTMLDialogElement.prototype.showModal; HTMLDialogElement.prototype.showModal = function (...args) { if (!this.hasAttribute('tabindex')) { this.setAttribute('tabindex', '-1'); } const result = _origShowModal.apply(this, args); const active = document.activeElement; if (active && active !== this && this.contains(active)) { active.blur(); this.focus({ preventScroll: true }); } return result; }; window.addEventListener('DOMContentLoaded', async () => { // Cache DOM references cacheDom(); // Initialize theme and accent color initTheme(); initAccentColor(); initPlayerFullscreen(); // Register service worker for PWA installability if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } // Build the editorial spectrum bars. Fewer, fatter bars read better // than many thin ones at this column width. JS-managed so we can // drive heights from real audio data when available. const spectrumRoot = document.getElementById('player-spectrum'); if (spectrumRoot && !spectrumRoot.children.length) { const SPECTRUM_BARS = 40; // CSS repeat() doesn't accept a var() for its count — set the // grid column template from JS so it always matches the bar // count and stretches each bar to claim 1fr of the row. spectrumRoot.style.gridTemplateColumns = `repeat(${SPECTRUM_BARS}, minmax(0, 1fr))`; const frag = document.createDocumentFragment(); for (let i = 0; i < SPECTRUM_BARS; i++) { const s = document.createElement('span'); // Pseudo-random heights for the synthetic CSS animation phase s.style.setProperty('--bar-h', (25 + Math.abs(Math.sin(i * 0.7)) * 70).toFixed(0) + '%'); s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's'); frag.appendChild(s); } spectrumRoot.appendChild(frag); } // Initialize audio visualizer — auto-enable when supported so the // spectrum shows real audio out of the box. checkVisualizerAvailability().then(() => { if (!visualizerAvailable) return; // First install: opt the user in by default since the spectrum // is the centerpiece of the player view. const stored = localStorage.getItem('visualizerEnabled'); const shouldEnable = stored === null ? true : stored === 'true'; if (shouldEnable) { setVisualizerEnabled(true); // updates the let in player.js applyVisualizerMode(); } }); // Initialize dynamic background applyDynamicBackground(); // Initialize locale (async - loads JSON file) await initLocale(); // Load version from health endpoint fetchVersion(); // Check if authentication is required let authReq = true; try { const healthResp = await fetch('/api/health'); const healthData = await healthResp.json(); authReq = healthData.auth_required !== false; } catch { /* assume auth required on error */ } setAuthRequired(authReq); const token = localStorage.getItem('media_server_token'); if (!authReq) { // No auth required — connect directly without token connectWebSocket(''); loadScripts(); loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); loadAudioDevices(); } else if (token) { connectWebSocket(token); loadScripts(); loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); loadAudioDevices(); } else { showAuthForm(); } // Shared volume slider setup (avoids duplicate handler code) function setupVolumeSlider(sliderId) { const slider = document.getElementById(sliderId); slider.addEventListener('input', (e) => { setIsUserAdjustingVolume(true); const volume = parseInt(e.target.value); // Sync both sliders and displays dom.volumeDisplay.textContent = `${volume}%`; dom.miniVolumeDisplay.textContent = `${volume}%`; dom.volumeSlider.value = volume; dom.miniVolumeSlider.value = volume; if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer); setVolumeUpdateTimer(setTimeout(() => { setVolume(volume); setVolumeUpdateTimer(null); }, VOLUME_THROTTLE_MS)); }); slider.addEventListener('change', (e) => { if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); setVolumeUpdateTimer(null); } const volume = parseInt(e.target.value); setVolume(volume); setTimeout(() => { setIsUserAdjustingVolume(false); }, VOLUME_RELEASE_DELAY_MS); }); } setupVolumeSlider('volume-slider'); setupVolumeSlider('mini-volume-slider'); // Restore saved tab (migrate old tab names) let savedTab = localStorage.getItem('activeTab') || 'player'; if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings'; switchTab(savedTab); // Snap indicator to initial position without animation const initialActiveBtn = document.querySelector('.tab-btn.active'); if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false); // Re-position tab indicator on window resize window.addEventListener('resize', () => { const activeBtn = document.querySelector('.tab-btn.active'); if (activeBtn) updateTabIndicator(activeBtn, false); }); // Mini Player: Intersection Observer to show/hide when main player scrolls out of view const playerContainer = document.querySelector('.player-container'); const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (activeTab !== 'player') return; setMiniPlayerVisible(!entry.isIntersecting); }); }, { threshold: 0.1 }); observer.observe(playerContainer); // Drag-to-seek for progress bars setupProgressDrag( document.getElementById('mini-progress-bar'), document.getElementById('mini-progress-fill') ); setupProgressDrag( document.getElementById('progress-bar'), document.getElementById('progress-fill') ); // Enter key in token input document.getElementById('token-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') { authenticate(); } }); // Script form dirty state tracking const scriptForm = document.getElementById('scriptForm'); scriptForm.addEventListener('input', () => { setScriptFormDirty(true); }); scriptForm.addEventListener('change', () => { setScriptFormDirty(true); }); // Callback form dirty state tracking const callbackForm = document.getElementById('callbackForm'); callbackForm.addEventListener('input', () => { setCallbackFormDirty(true); }); callbackForm.addEventListener('change', () => { setCallbackFormDirty(true); }); // Script dialog backdrop click to close const scriptDialog = document.getElementById('scriptDialog'); scriptDialog.addEventListener('click', (e) => { if (e.target === scriptDialog) { closeScriptDialog(); } }); // Callback dialog backdrop click to close const callbackDialog = document.getElementById('callbackDialog'); callbackDialog.addEventListener('click', (e) => { if (e.target === callbackDialog) { closeCallbackDialog(); } }); // Delegated click handlers for script table actions (XSS-safe) document.getElementById('scriptsTableBody').addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const name = btn.dataset.scriptName; if (action === 'execute') executeScriptDebug(name); else if (action === 'edit') showEditScriptDialog(name); else if (action === 'delete') deleteScriptConfirm(name); }); // Delegated click handlers for callback table actions (XSS-safe) document.getElementById('callbacksTableBody').addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const name = btn.dataset.callbackName; if (action === 'execute') executeCallbackDebug(name); else if (action === 'edit') showEditCallbackDialog(name); else if (action === 'delete') deleteCallbackConfirm(name); }); // Folder dialog backdrop click to close const folderDialog = document.getElementById('folderDialog'); folderDialog.addEventListener('click', (e) => { if (e.target === folderDialog) { closeFolderDialog(); } }); // Delegated click handlers for folder table actions document.getElementById('foldersTableBody').addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const folderId = btn.dataset.folderId; if (action === 'edit') showEditFolderDialog(folderId); else if (action === 'delete') deleteFolderConfirm(folderId); }); // Link dialog backdrop click to close const linkDialog = document.getElementById('linkDialog'); linkDialog.addEventListener('click', (e) => { if (e.target === linkDialog) { closeLinkDialog(); } }); // Delegated click handlers for link table actions (XSS-safe) document.getElementById('linksTableBody').addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const name = btn.dataset.linkName; if (action === 'edit') showEditLinkDialog(name); else if (action === 'delete') deleteLinkConfirm(name); }); // Track link form dirty state const linkForm = document.getElementById('linkForm'); linkForm.addEventListener('input', () => { setLinkFormDirty(true); }); linkForm.addEventListener('change', () => { setLinkFormDirty(true); }); // Initialize browser toolbar and load folders initBrowserToolbar(); if (!authReq || token) { loadMediaFolders(); } // Icon preview for script and link dialogs setupIconPreview('scriptIcon', 'scriptIconPreview'); setupIconPreview('linkIcon', 'linkIconPreview'); // Settings sections: restore collapse state and persist on toggle document.querySelectorAll('.settings-section').forEach(details => { const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`; const saved = localStorage.getItem(key); if (saved === 'closed') details.removeAttribute('open'); else if (saved === 'open') details.setAttribute('open', ''); details.addEventListener('toggle', () => { localStorage.setItem(key, details.open ? 'open' : 'closed'); }); }); // Cleanup blob URLs on page unload window.addEventListener('beforeunload', () => { thumbnailCache.forEach(url => URL.revokeObjectURL(url)); thumbnailCache.clear(); }); // Tab bar keyboard navigation (WAI-ARIA Tabs pattern) document.getElementById('tabBar').addEventListener('keydown', (e) => { const tabs = Array.from(document.querySelectorAll('.tab-btn')); const currentIdx = tabs.indexOf(document.activeElement); if (currentIdx === -1) return; let newIdx; if (e.key === 'ArrowRight') { newIdx = (currentIdx + 1) % tabs.length; } else if (e.key === 'ArrowLeft') { newIdx = (currentIdx - 1 + tabs.length) % tabs.length; } else if (e.key === 'Home') { newIdx = 0; } else if (e.key === 'End') { newIdx = tabs.length - 1; } else { return; } e.preventDefault(); tabs[newIdx].focus(); switchTab(tabs[newIdx].dataset.tab); }); // Global keyboard shortcuts document.addEventListener('keydown', (e) => { // Skip when typing in inputs, textareas, selects, or when a dialog is open const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (document.querySelector('dialog[open]')) return; switch (e.key) { case ' ': e.preventDefault(); togglePlayPause(); break; case 'ArrowLeft': e.preventDefault(); if (currentDuration > 0) seek(Math.max(0, currentPosition - 5)); break; case 'ArrowRight': e.preventDefault(); if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5)); break; case 'ArrowUp': e.preventDefault(); setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5)); break; case 'ArrowDown': e.preventDefault(); setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5)); break; case 'm': case 'M': toggleMute(); break; } }); });