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

${text}

`; } // Media source registry: substring key → { name, icon } 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: '' }, }; 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) const dom = {}; 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.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 const VOLUME_THROTTLE_MS = 16; const POSITION_INTERPOLATION_MS = 100; const SEARCH_DEBOUNCE_MS = 200; const TOAST_DURATION_MS = 3000; const WS_BACKOFF_BASE_MS = 3000; const WS_BACKOFF_MAX_MS = 30000; const WS_MAX_RECONNECT_ATTEMPTS = 20; const WS_PING_INTERVAL_MS = 30000; const VOLUME_RELEASE_DELAY_MS = 500; // Shared state (accessed across multiple modules) let ws = null; let currentState = 'idle'; let currentDuration = 0; let currentPosition = 0; let isUserAdjustingVolume = false; let volumeUpdateTimer = null; let scripts = []; let lastStatus = null; let currentPlayState = 'idle'; // ============================================================ // 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' }; 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'; } 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'); } 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; } } 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 updatePlaybackState(currentState); const connected = ws && ws.readyState === WebSocket.OPEN; 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 || ''; } const token = localStorage.getItem('media_server_token'); if (token) { loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); displayQuickAccess(); } renderAccentSwatches(); } 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}`; } } } catch (error) { console.error('Error fetching version:', error); } } // ============================================================ // Shared Utilities // ============================================================ 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')}`; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } 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); } function closeDialog(dialog) { dialog.classList.add('dialog-closing'); dialog.addEventListener('animationend', () => { dialog.classList.remove('dialog-closing'); dialog.close(); }, { once: true }); } 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(); }); } // ============================================================ // API Commands // ============================================================ async function sendCommand(endpoint, body = null) { const token = localStorage.getItem('media_server_token'); const options = { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }; 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'); } } function togglePlayPause() { if (currentState === 'playing') { sendCommand('pause'); } else { sendCommand('play'); } } function nextTrack() { sendCommand('next'); } function previousTrack() { sendCommand('previous'); } let lastSentVolume = -1; 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 }); } } function toggleMute() { sendCommand('mute'); } 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 {} } async function fetchMdiIcon(iconName) { const name = iconName.replace(/^mdi:/, ''); if (mdiIconCache[name]) return mdiIconCache[name]; try { const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`); if (response.ok) { const svg = await response.text(); mdiIconCache[name] = svg; _persistMdiCache(); return svg; } } catch (e) { console.warn('Failed to fetch MDI icon:', name, e); } return ''; } 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); } })); } 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); }); }