// ============================================================ // 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.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; export const POSITION_INTERPOLATION_MS = 100; 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 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 || ''; } const token = localStorage.getItem('media_server_token'); if (token) { 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}`; } } } catch (error) { console.error('Error fetching version:', error); } } // ============================================================ // 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 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 // ============================================================ export 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'); } } 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 }); } } 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 {} } export 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 ''; } 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); }); }