Files
media-player-server/media_server/static/js/app.js
T
alexei.dolgolyov 968eb156bc fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl
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).
2026-04-25 02:27:56 +03:00

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;
}
});
});