db777fa64b
Lint & Test / test (push) Successful in 1m18s
Patches HTMLDialogElement.prototype.showModal globally to move focus onto the dialog element itself instead of the first focusable descendant. On touch devices the previous behavior popped up the on-screen keyboard whenever a modal opened, which was confusing.
476 lines
17 KiB
JavaScript
476 lines
17 KiB
JavaScript
// ============================================================
|
|
// 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,
|
|
toggleVinylMode, applyVinylMode,
|
|
visualizerEnabled, visualizerAvailable,
|
|
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
|
loadAudioDevices, onAudioDeviceChanged,
|
|
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
|
} 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,
|
|
// Vinyl & visualizer
|
|
toggleVinylMode, toggleVisualizer,
|
|
// Background
|
|
toggleDynamicBackground,
|
|
// 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 <dialog>.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();
|
|
|
|
// Register service worker for PWA installability
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
}
|
|
|
|
// Initialize vinyl mode
|
|
applyVinylMode();
|
|
|
|
// Initialize audio visualizer
|
|
checkVisualizerAvailability().then(() => {
|
|
if (visualizerEnabled && visualizerAvailable) {
|
|
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;
|
|
}
|
|
});
|
|
});
|