a0f74dfc39
Spectrum width - grid-auto-flow: column with implicit columns wasn't reliably stretching to fill the parent. Switch to explicit grid-template-columns: repeat(var(--spectrum-bars), minmax(0, 1fr)) with the bar count exposed as a CSS variable from JS so the column count and the actual bar count stay in sync. - !important on display/grid-template-columns/width to defeat any legacy descendant rules. Device selection - Picking a device in the audio-device dropdown is an explicit signal that the user wants capture. Auto-enable the visualizer if it isn't already on, then call applyVisualizerMode so the WS subscription happens and the badge flips from 'Available' to 'Active'. Was only doing this when visualizer was already on, which is why the user kept seeing 'Available, not capturing'.
503 lines
18 KiB
JavaScript
503 lines
18 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, setVisualizerEnabled,
|
|
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(() => {});
|
|
}
|
|
|
|
// Vinyl is now structural / always-on via CSS — no init call needed.
|
|
// applyVinylMode();
|
|
|
|
// 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;
|
|
// Sync the grid column count to the bar count so the row truly
|
|
// fills the column even if the bar count changes later.
|
|
spectrumRoot.style.setProperty('--spectrum-bars', SPECTRUM_BARS);
|
|
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;
|
|
}
|
|
});
|
|
});
|