Refactor monolithic app.js into 8 modular files
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>
This commit is contained in:
286
media_server/static/js/main.js
Normal file
286
media_server/static/js/main.js
Normal file
@@ -0,0 +1,286 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user