// ============================================================ // Core: Shared state, constants, utilities, i18n, API commands // ============================================================ // SVG path constants (avoid rebuilding innerHTML on every state update) export const SVG_PLAY = ''; export const SVG_PAUSE = ''; export const SVG_STOP = ''; export const SVG_IDLE = ''; export const SVG_MUTED = ''; export const SVG_UNMUTED = ''; // Empty state illustration SVGs export const EMPTY_SVG_FOLDER = ''; export const EMPTY_SVG_FILE = ''; export function emptyStateHtml(svgStr, text) { return `
${svgStr}

${text}

`; } // Media source registry: substring key → { name, icon } export const MEDIA_SOURCES = { 'spotify': { name: 'Spotify', icon: '' }, 'yandex music': { name: 'Yandex Music', icon: '' }, 'яндекс музыка': { name: 'Яндекс Музыка', icon: '' }, 'chrome': { name: 'Google Chrome', icon: '' }, 'msedge': { name: 'Microsoft Edge', icon: '' }, 'firefox': { name: 'Firefox', icon: '' }, 'opera': { name: 'Opera', icon: '' }, 'brave': { name: 'Brave', icon: '' }, 'yandex': { name: 'Yandex Browser', icon: '' }, 'vlc': { name: 'VLC', icon: '' }, 'aimp': { name: 'AIMP', icon: '' }, 'foobar': { name: 'foobar2000', icon: '' }, 'music.ui': { name: 'Groove Music', icon: '' }, 'itunes': { name: 'iTunes', icon: '' }, 'apple music': { name: 'Apple Music', icon: '' }, 'deezer': { name: 'Deezer', icon: '' }, 'tidal': { name: 'TIDAL', icon: '' }, }; export function resolveMediaSource(raw) { if (!raw) return null; const lower = raw.toLowerCase(); for (const [key, info] of Object.entries(MEDIA_SOURCES)) { if (lower.includes(key)) return info; } return { name: raw.replace(/\.exe$/i, ''), icon: null }; } // Cached DOM references (populated once after DOMContentLoaded) export const dom = {}; export function cacheDom() { dom.trackTitle = document.getElementById('track-title'); dom.artist = document.getElementById('artist'); dom.album = document.getElementById('album'); dom.miniTrackTitle = document.getElementById('mini-track-title'); dom.miniArtist = document.getElementById('mini-artist'); dom.albumArt = document.getElementById('album-art'); dom.albumArtGlow = document.getElementById('album-art-glow'); dom.miniAlbumArt = document.getElementById('mini-album-art'); dom.volumeSlider = document.getElementById('volume-slider'); dom.volumeDisplay = document.getElementById('volume-display'); dom.miniVolumeSlider = document.getElementById('mini-volume-slider'); dom.miniVolumeDisplay = document.getElementById('mini-volume-display'); dom.progressFill = document.getElementById('progress-fill'); dom.currentTime = document.getElementById('current-time'); dom.totalTime = document.getElementById('total-time'); dom.metaElapsed = document.getElementById('meta-elapsed'); dom.metaLength = document.getElementById('meta-length'); dom.progressBar = document.getElementById('progress-bar'); dom.miniProgressFill = document.getElementById('mini-progress-fill'); dom.miniCurrentTime = document.getElementById('mini-current-time'); dom.miniTotalTime = document.getElementById('mini-total-time'); dom.playbackState = document.getElementById('playback-state'); dom.stateIcon = document.getElementById('state-icon'); dom.playPauseIcon = document.getElementById('play-pause-icon'); dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); dom.muteIcon = document.getElementById('mute-icon'); dom.miniMuteIcon = document.getElementById('mini-mute-icon'); dom.statusDot = document.getElementById('status-dot'); dom.source = document.getElementById('source'); dom.sourceIcon = document.getElementById('sourceIcon'); dom.btnPlayPause = document.getElementById('btn-play-pause'); dom.btnNext = document.getElementById('btn-next'); dom.btnPrevious = document.getElementById('btn-previous'); dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause'); dom.miniPlayer = document.getElementById('mini-player'); } // Timing constants export const VOLUME_THROTTLE_MS = 16; // 250ms is plenty for sub-second progress; the inline updateProgress // also short-circuits when the rounded second hasn't moved, so there's // no visible difference for the user. export const POSITION_INTERPOLATION_MS = 250; export const SEARCH_DEBOUNCE_MS = 200; export const TOAST_DURATION_MS = 3000; export const WS_BACKOFF_BASE_MS = 3000; export const WS_BACKOFF_MAX_MS = 30000; export const WS_MAX_RECONNECT_ATTEMPTS = 20; export const WS_PING_INTERVAL_MS = 30000; export const VOLUME_RELEASE_DELAY_MS = 500; // Shared state (accessed across multiple modules) export let ws = null; export function setWs(value) { ws = value; } export function getWs() { return ws; } export let currentState = 'idle'; export function setCurrentState(value) { currentState = value; } export let currentDuration = 0; export function setCurrentDuration(value) { currentDuration = value; } export let currentPosition = 0; export function setCurrentPosition(value) { currentPosition = value; } export let isUserAdjustingVolume = false; export function setIsUserAdjustingVolume(value) { isUserAdjustingVolume = value; } export let volumeUpdateTimer = null; export function setVolumeUpdateTimer(value) { volumeUpdateTimer = value; } export let scripts = []; export function setScripts(value) { scripts = value; } export let lastStatus = null; export function setLastStatus(value) { lastStatus = value; } export let currentPlayState = 'idle'; export function setCurrentPlayState(value) { currentPlayState = value; } // ============================================================ // Internationalization (i18n) // ============================================================ let currentLocale = 'en'; let translations = {}; const supportedLocales = { 'en': 'English', 'ru': 'Русский' }; // Minimal inline fallback for critical UI elements const fallbackTranslations = { 'app.title': 'Media Server', 'auth.connect': 'Connect', 'auth.placeholder': 'Enter API Token', 'player.status.connected': 'Connected', 'player.status.disconnected': 'Disconnected' }; export function t(key, params = {}) { let text = translations[key] || fallbackTranslations[key] || key; Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); return text; } async function loadTranslations(locale) { try { const response = await fetch(`/static/locales/${locale}.json`); if (!response.ok) { throw new Error(`Failed to load ${locale}.json`); } return await response.json(); } catch (error) { console.error(`Error loading translations for ${locale}:`, error); if (locale !== 'en') { return await loadTranslations('en'); } return {}; } } function detectBrowserLocale() { const browserLang = navigator.language || navigator.languages?.[0] || 'en'; const langCode = browserLang.split('-')[0]; return supportedLocales[langCode] ? langCode : 'en'; } export async function initLocale() { const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); await setLocale(savedLocale); } async function setLocale(locale) { if (!supportedLocales[locale]) { locale = 'en'; } translations = await loadTranslations(locale); currentLocale = locale; document.documentElement.setAttribute('data-locale', locale); document.documentElement.setAttribute('lang', locale); localStorage.setItem('locale', locale); updateAllText(); updateLocaleSelect(); document.body.classList.remove('loading-translations'); document.body.classList.add('translations-loaded'); } export function changeLocale() { const select = document.getElementById('locale-select'); const newLocale = select.value; if (newLocale && newLocale !== currentLocale) { localStorage.setItem('locale', newLocale); setLocale(newLocale); } } function updateLocaleSelect() { const select = document.getElementById('locale-select'); if (select) { select.value = currentLocale; } } // Note: updateAllText calls functions from other modules via late-bound references. // These are set from app.js after all modules are loaded. let _updatePlaybackState = null; let _updateConnectionStatus = null; let _loadScriptsTable = null; let _loadCallbacksTable = null; let _loadLinksTable = null; let _displayQuickAccess = null; let _renderAccentSwatches = null; export function registerUpdateCallbacks(callbacks) { _updatePlaybackState = callbacks.updatePlaybackState; _updateConnectionStatus = callbacks.updateConnectionStatus; _loadScriptsTable = callbacks.loadScriptsTable; _loadCallbacksTable = callbacks.loadCallbacksTable; _loadLinksTable = callbacks.loadLinksTable; _displayQuickAccess = callbacks.displayQuickAccess; _renderAccentSwatches = callbacks.renderAccentSwatches; } function updateAllText() { document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); el.placeholder = t(key); }); document.querySelectorAll('[data-i18n-title]').forEach(el => { const key = el.getAttribute('data-i18n-title'); el.title = t(key); }); // Re-apply dynamic content with new translations if (_updatePlaybackState) _updatePlaybackState(currentState); const connected = ws && ws.readyState === WebSocket.OPEN; if (_updateConnectionStatus) _updateConnectionStatus(connected); if (lastStatus) { const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle; const initSrc = resolveMediaSource(lastStatus.source); document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source'); document.getElementById('sourceIcon').innerHTML = initSrc?.icon || ''; } if (hasCredentials()) { if (_loadScriptsTable) _loadScriptsTable(); if (_loadCallbacksTable) _loadCallbacksTable(); if (_loadLinksTable) _loadLinksTable(); if (_displayQuickAccess) _displayQuickAccess(); } if (_renderAccentSwatches) _renderAccentSwatches(); } export async function fetchVersion() { try { const response = await fetch('/api/health'); if (response.ok) { const data = await response.json(); const label = document.getElementById('version-label'); if (data.version) { label.textContent = `v${data.version}`; const folioVersion = document.getElementById('folio-version'); if (folioVersion) folioVersion.textContent = `v${data.version}`; } if (data.update_available) { showUpdateBanner(data.update_available); } } } catch (error) { console.error('Error fetching version:', error); } } export function showUpdateBanner(update) { const dismissed = sessionStorage.getItem('update_dismissed'); if (dismissed === update.latest) return; const banner = document.getElementById('updateBanner'); const text = document.getElementById('updateBannerText'); const link = document.getElementById('updateBannerLink'); const closeBtn = document.getElementById('updateBannerClose'); text.textContent = t('update.available', { version: update.latest }); link.href = update.url; link.textContent = t('update.view_release'); banner.classList.remove('hidden'); closeBtn.onclick = () => { banner.classList.add('hidden'); sessionStorage.setItem('update_dismissed', update.latest); }; } // ============================================================ // Shared Utilities // ============================================================ export function formatTime(seconds) { if (!seconds || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } export function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } export function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('show'); }); setTimeout(() => { toast.classList.remove('show'); toast.addEventListener('transitionend', () => toast.remove(), { once: true }); setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500); }, TOAST_DURATION_MS); } export function closeDialog(dialog) { dialog.classList.add('dialog-closing'); dialog.addEventListener('animationend', () => { dialog.classList.remove('dialog-closing'); dialog.close(); }, { once: true }); } export function showAboutDialog() { const dialog = document.getElementById('aboutDialog'); if (dialog) dialog.showModal(); } export function closeAboutDialog() { const dialog = document.getElementById('aboutDialog'); if (dialog) closeDialog(dialog); } export function showConfirm(message) { return new Promise((resolve) => { const dialog = document.getElementById('confirmDialog'); const msg = document.getElementById('confirmDialogMessage'); const btnCancel = document.getElementById('confirmDialogCancel'); const btnConfirm = document.getElementById('confirmDialogConfirm'); msg.textContent = message; function cleanup() { btnCancel.removeEventListener('click', onCancel); btnConfirm.removeEventListener('click', onConfirm); dialog.removeEventListener('close', onClose); closeDialog(dialog); } function onCancel() { cleanup(); resolve(false); } function onConfirm() { cleanup(); resolve(true); } function onClose() { cleanup(); resolve(false); } btnCancel.addEventListener('click', onCancel); btnConfirm.addEventListener('click', onConfirm); dialog.addEventListener('close', onClose); dialog.showModal(); }); } // ============================================================ // Auth Helpers // ============================================================ // Set to false when server reports auth_required: false export let authRequired = true; export function setAuthRequired(value) { authRequired = value; } /** * Build Authorization headers for API requests. * Returns empty object when auth is disabled or no token is stored. */ export function getAuthHeaders() { const token = localStorage.getItem('media_server_token'); return token ? { 'Authorization': `Bearer ${token}` } : {}; } /** * Check if we have sufficient credentials to call the API. * True when auth is disabled OR a token is stored. */ export function hasCredentials() { return !authRequired || !!localStorage.getItem('media_server_token'); } // ============================================================ // API Commands // ============================================================ export async function sendCommand(endpoint, body = null) { const options = { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, }; if (body) { options.body = JSON.stringify(body); } try { const response = await fetch(`/api/media/${endpoint}`, options); if (!response.ok) { const data = await response.json().catch(() => ({})); console.error(`Command ${endpoint} failed:`, response.status); showToast(data.detail || `Command failed: ${endpoint}`, 'error'); } } catch (error) { console.error(`Error sending command ${endpoint}:`, error); showToast(`Connection error: ${endpoint}`, 'error'); } } export function togglePlayPause() { if (currentState === 'playing') { sendCommand('pause'); } else { sendCommand('play'); } } export function nextTrack() { sendCommand('next'); } export function previousTrack() { sendCommand('previous'); } let lastSentVolume = -1; export function setVolume(volume) { if (volume === lastSentVolume) return; lastSentVolume = volume; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'volume', volume: volume })); } else { sendCommand('volume', { volume: volume }); } } // Reset the de-dupe cache whenever the server reports a fresh volume value // (e.g., another client moved the slider). Otherwise the user can end up // unable to "set volume back to the value we last sent" after a remote change. export function notifyRemoteVolume(volume) { lastSentVolume = volume; } export function toggleMute() { sendCommand('mute'); } export function seek(position) { sendCommand('seek', { position: position }); } // ============================================================ // MDI Icon System // ============================================================ const mdiIconCache = (() => { try { return JSON.parse(localStorage.getItem('mdiIconCache') || '{}'); } catch { return {}; } })(); function _persistMdiCache() { try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} } // Strict iconify MDI slug — used to reject anything that could be path-traversal // or query injection before we even hit the network. const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; function sanitizeSvg(rawSvg) { // Parse the SVG and strip anything that could execute script. Anything // unparseable returns null so callers fall back to the placeholder. try { const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml'); const root = doc.documentElement; if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) { return null; } const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); const toRemove = []; let node = walker.currentNode; while (node) { const tag = node.tagName?.toLowerCase(); if (tag === 'script' || tag === 'foreignobject') { toRemove.push(node); } else if (node.attributes) { for (const attr of Array.from(node.attributes)) { const name = attr.name.toLowerCase(); if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && /^\s*(javascript|data):/i.test(attr.value))) { node.removeAttribute(attr.name); } } } node = walker.nextNode(); } toRemove.forEach((el) => el.remove()); return root.outerHTML; } catch { return null; } } const PLACEHOLDER_SVG = ''; export async function fetchMdiIcon(iconName) { const name = String(iconName || '').replace(/^mdi:/, ''); if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG; if (mdiIconCache[name]) return mdiIconCache[name]; try { const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`); if (response.ok) { const raw = await response.text(); const safe = sanitizeSvg(raw); if (safe) { mdiIconCache[name] = safe; _persistMdiCache(); return safe; } } } catch (e) { console.warn('Failed to fetch MDI icon:', name, e); } return PLACEHOLDER_SVG; } export async function resolveMdiIcons(container) { const els = container.querySelectorAll('[data-mdi-icon]'); await Promise.all(Array.from(els).map(async (el) => { const icon = el.dataset.mdiIcon; if (icon) { el.innerHTML = await fetchMdiIcon(icon); } })); } export function setupIconPreview(inputId, previewId) { const input = document.getElementById(inputId); const preview = document.getElementById(previewId); if (!input || !preview) return; let debounceTimer = null; input.addEventListener('input', () => { clearTimeout(debounceTimer); const value = input.value.trim(); if (!value) { preview.innerHTML = ''; return; } debounceTimer = setTimeout(async () => { const svg = await fetchMdiIcon(value); if (input.value.trim() === value) { preview.innerHTML = svg; } }, 400); }); }