968eb156bc
VU needle reflects actual audio output - Was just a synthetic wobble bounded by the volume slider value. Now reads RMS of the FFT bins (skipping bin 0 / DC) the visualizer feeds in, multiplies by current volume, and applies attack/release smoothing for analog-feeling ballistics. - Falls back to the synthetic wobble when audio capture isn't running so the needle still looks alive on the static fallback. - When playback stops, needle settles to the bottom of the swing (-45deg) instead of holding the volume position. Spectrum width — actually fixed this time - Root cause: CSS repeat() does NOT accept a CSS variable for its count argument, so my `repeat(var(--spectrum-bars), 1fr)` rule was invalid and silently dropped, leaving the legacy/auto sizing behavior. Set grid-template-columns directly from JS to `repeat(40, minmax(0, 1fr))`. - CSS retains a `repeat(40, minmax(0, 1fr))` literal as a default so the row renders sane even before JS executes. Spectrogram canvas under vinyl - Hidden via display: none — the editorial .spectrum row already shows the audio spectrum; the canvas was redundant and ugly. Element stays in DOM so the visualizer JS keeps rendering (drives album-art bass-pulse + dynamic background bands).
505 lines
19 KiB
JavaScript
505 lines
19 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;
|
|
// 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;
|
|
}
|
|
});
|
|
});
|