Split 3803-line app.js into focused modules: - core.js: shared state, utilities, i18n, API commands, MDI icons - player.js: tabs, theme, accent, vinyl, visualizer, UI updates - websocket.js: connection, auth, reconnection - scripts.js: scripts CRUD, quick access, execution dialog - callbacks.js: callbacks CRUD - browser.js: media file browser, thumbnails, pagination, search - links.js: links CRUD, header links, display controls - main.js: DOMContentLoaded init orchestrator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
287 lines
10 KiB
JavaScript
287 lines
10 KiB
JavaScript
// ============================================================
|
|
// Main: Initialization orchestrator (loaded last)
|
|
// ============================================================
|
|
|
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
// Cache DOM references
|
|
cacheDom();
|
|
|
|
// Initialize theme and accent color
|
|
initTheme();
|
|
initAccentColor();
|
|
|
|
// Initialize vinyl mode
|
|
applyVinylMode();
|
|
|
|
// Initialize audio visualizer
|
|
checkVisualizerAvailability().then(() => {
|
|
if (visualizerEnabled && visualizerAvailable) {
|
|
applyVisualizerMode();
|
|
}
|
|
});
|
|
|
|
// Initialize locale (async - loads JSON file)
|
|
await initLocale();
|
|
|
|
// Load version from health endpoint
|
|
fetchVersion();
|
|
|
|
const token = localStorage.getItem('media_server_token');
|
|
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) => {
|
|
isUserAdjustingVolume = 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);
|
|
volumeUpdateTimer = setTimeout(() => {
|
|
setVolume(volume);
|
|
volumeUpdateTimer = null;
|
|
}, VOLUME_THROTTLE_MS);
|
|
});
|
|
|
|
slider.addEventListener('change', (e) => {
|
|
if (volumeUpdateTimer) {
|
|
clearTimeout(volumeUpdateTimer);
|
|
volumeUpdateTimer = null;
|
|
}
|
|
const volume = parseInt(e.target.value);
|
|
setVolume(volume);
|
|
setTimeout(() => { isUserAdjustingVolume = 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', () => {
|
|
scriptFormDirty = true;
|
|
});
|
|
scriptForm.addEventListener('change', () => {
|
|
scriptFormDirty = true;
|
|
});
|
|
|
|
// Callback form dirty state tracking
|
|
const callbackForm = document.getElementById('callbackForm');
|
|
callbackForm.addEventListener('input', () => {
|
|
callbackFormDirty = true;
|
|
});
|
|
callbackForm.addEventListener('change', () => {
|
|
callbackFormDirty = true;
|
|
});
|
|
|
|
// Script dialog backdrop click to close
|
|
const scriptDialog = document.getElementById('scriptDialog');
|
|
scriptDialog.addEventListener('click', (e) => {
|
|
// Check if click is on the backdrop (not the dialog content)
|
|
if (e.target === scriptDialog) {
|
|
closeScriptDialog();
|
|
}
|
|
});
|
|
|
|
// Callback dialog backdrop click to close
|
|
const callbackDialog = document.getElementById('callbackDialog');
|
|
callbackDialog.addEventListener('click', (e) => {
|
|
// Check if click is on the backdrop (not the dialog content)
|
|
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);
|
|
});
|
|
|
|
// 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', () => {
|
|
linkFormDirty = true;
|
|
});
|
|
linkForm.addEventListener('change', () => {
|
|
linkFormDirty = true;
|
|
});
|
|
|
|
// Initialize browser toolbar and load folders
|
|
initBrowserToolbar();
|
|
if (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;
|
|
}
|
|
});
|
|
});
|