Files
media-player-server/media_server/static/js/app.js
T
alexei.dolgolyov 265b001b99 fix(ui): close gaps with Studio Reference mockup
Side-by-side comparison surfaced several layout regressions vs. the
mockup. This commit lands all of them at once.

Header
- Restore centered "Media Server / Studio Reference Edition" wordmark
  in italic Fraunces
- Move folio marks to fixed page corners (visible on every tab):
  TL = green pulse + "Connected · Local 8765"
  TR = "Vol. I — Studio Reference · v0.x.x"
- Replace boxed version-label badge with copper mono inline in folio.tr
- Reduce header-to-content gap (container padding-top 28→56 with the
  folio now anchored above)

Player view
- Spectrum bars: smaller height (32px), centered with max-width so
  they don't span the whole right column
- Spectrogram canvas: hidden by default (opacity 0); reveals only when
  visualizer toggle is active. No more leaking into bottom-left.
- VU cluster volume controls: strip legacy box (background, padding,
  border-radius); compact stacked layout with thin slider, small mute
  button, mono "VOL · XX%" readout
- Disable legacy applyVinylMode() — the .vinyl class added a SECOND
  rotation animation on top of the structural .vinyl-stage spin,
  causing visible compounding. Vinyl is now purely structural.

Toggles
- Remove vinyl mode toggle button (vinyl is always on)
- Keep audio visualizer (spectrum vis) toggle — still shown by JS
  when supported

Mini player
- Force always-visible on non-player tabs regardless of scroll, by
  short-circuiting setMiniPlayerVisible when activeTab !== 'player'

i18n
- New keys: header.connected, header.volume, header.edition,
  header.edition_sub
- Removed unused: player.folio_left, player.folio_right
- en.json + ru.json updated
2026-04-25 01:32:34 +03:00

476 lines
17 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,
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();
// Initialize audio visualizer
checkVisualizerAvailability().then(() => {
if (visualizerEnabled && visualizerAvailable) {
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;
}
});
});