// 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}

`; } // 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.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; // Tab management let activeTab = 'player'; function setMiniPlayerVisible(visible) { const miniPlayer = document.getElementById('mini-player'); if (visible) { miniPlayer.classList.remove('hidden'); document.body.classList.add('mini-player-visible'); } else { miniPlayer.classList.add('hidden'); document.body.classList.remove('mini-player-visible'); } } function updateTabIndicator(btn, animate = true) { const indicator = document.getElementById('tabIndicator'); if (!indicator || !btn) return; const tabBar = document.getElementById('tabBar'); const barRect = tabBar.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0); if (!animate) indicator.style.transition = 'none'; indicator.style.width = btnRect.width + 'px'; indicator.style.transform = `translateX(${offset}px)`; if (!animate) { // Force reflow, then re-enable transition indicator.offsetHeight; indicator.style.transition = ''; } } function switchTab(tabName) { activeTab = tabName; // Hide all tab content document.querySelectorAll('[data-tab-content]').forEach(el => { el.classList.remove('active'); el.style.display = ''; }); // Show selected tab content const target = document.querySelector(`[data-tab-content="${tabName}"]`); if (target) { target.classList.add('active'); } // Update tab buttons and ARIA state document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.remove('active'); btn.setAttribute('aria-selected', 'false'); btn.setAttribute('tabindex', '-1'); }); const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); if (activeBtn) { activeBtn.classList.add('active'); activeBtn.setAttribute('aria-selected', 'true'); activeBtn.setAttribute('tabindex', '0'); updateTabIndicator(activeBtn); } // Load display monitors when switching to display tab if (tabName === 'display') { loadDisplayMonitors(); } // Save to localStorage localStorage.setItem('activeTab', tabName); // Mini-player: show when not on player tab if (tabName !== 'player') { setMiniPlayerVisible(true); } else { // Restore scroll-based behavior: check if player is in view const playerContainer = document.querySelector('.player-container'); const rect = playerContainer.getBoundingClientRect(); const inView = rect.top < window.innerHeight && rect.bottom > 0; setMiniPlayerVisible(!inView); } } // Theme management function initTheme() { const savedTheme = localStorage.getItem('theme') || 'dark'; setTheme(savedTheme); } function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); const sunIcon = document.getElementById('theme-icon-sun'); const moonIcon = document.getElementById('theme-icon-moon'); if (theme === 'light') { sunIcon.style.display = 'none'; moonIcon.style.display = 'block'; } else { sunIcon.style.display = 'block'; moonIcon.style.display = 'none'; } } function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme); } // Accent color management const accentPresets = [ { name: 'Green', color: '#1db954', hover: '#1ed760' }, { name: 'Blue', color: '#3b82f6', hover: '#60a5fa' }, { name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' }, { name: 'Pink', color: '#ec4899', hover: '#f472b6' }, { name: 'Orange', color: '#f97316', hover: '#fb923c' }, { name: 'Red', color: '#ef4444', hover: '#f87171' }, { name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' }, { name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' }, { name: 'Yellow', color: '#eab308', hover: '#facc15' }, ]; function lightenColor(hex, percent) { const num = parseInt(hex.replace('#', ''), 16); const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100)); const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100)); const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100)); return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } function initAccentColor() { const saved = localStorage.getItem('accentColor'); if (saved) { const preset = accentPresets.find(p => p.color === saved); if (preset) { applyAccentColor(preset.color, preset.hover); } else { applyAccentColor(saved, lightenColor(saved, 15)); } } renderAccentSwatches(); } function applyAccentColor(color, hover) { document.documentElement.style.setProperty('--accent', color); document.documentElement.style.setProperty('--accent-hover', hover); localStorage.setItem('accentColor', color); const dot = document.getElementById('accentDot'); if (dot) dot.style.background = color; } function renderAccentSwatches() { const dropdown = document.getElementById('accentDropdown'); if (!dropdown) return; const current = localStorage.getItem('accentColor') || '#1db954'; const isCustom = !accentPresets.some(p => p.color === current); const swatches = accentPresets.map(p => `
` ).join(''); const customRow = `
${t('accent.custom')}
`; dropdown.innerHTML = swatches + customRow; } function selectAccentColor(color, hover) { applyAccentColor(color, hover); renderAccentSwatches(); document.getElementById('accentDropdown').classList.remove('open'); } function toggleAccentPicker() { document.getElementById('accentDropdown').classList.toggle('open'); } document.addEventListener('click', (e) => { if (!e.target.closest('.accent-picker')) { document.getElementById('accentDropdown')?.classList.remove('open'); } }); // Vinyl mode let vinylMode = localStorage.getItem('vinylMode') === 'true'; let currentPlayState = 'idle'; function toggleVinylMode() { vinylMode = !vinylMode; localStorage.setItem('vinylMode', vinylMode); applyVinylMode(); } function applyVinylMode() { const container = document.querySelector('.album-art-container'); const btn = document.getElementById('vinylToggle'); if (!container) return; if (vinylMode) { container.classList.add('vinyl'); if (btn) btn.classList.add('active'); updateVinylSpin(); } else { container.classList.remove('vinyl', 'spinning', 'paused'); if (btn) btn.classList.remove('active'); } } function updateVinylSpin() { const container = document.querySelector('.album-art-container'); if (!container || !vinylMode) return; container.classList.remove('spinning', 'paused'); if (currentPlayState === 'playing') { container.classList.add('spinning'); } else if (currentPlayState === 'paused') { container.classList.add('paused'); } } // Locale management 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' }; // Translation function function t(key, params = {}) { let text = translations[key] || fallbackTranslations[key] || key; // Replace parameters like {name}, {value}, etc. Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); return text; } // Load translation file 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); // Fallback to English if loading fails if (locale !== 'en') { return await loadTranslations('en'); } return {}; } } // Detect browser locale function detectBrowserLocale() { const browserLang = navigator.language || navigator.languages?.[0] || 'en'; const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' // Only return if we support it return supportedLocales[langCode] ? langCode : 'en'; } // Initialize locale async function initLocale() { const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); await setLocale(savedLocale); } // Set locale async function setLocale(locale) { if (!supportedLocales[locale]) { locale = 'en'; } // Load translations for the locale translations = await loadTranslations(locale); currentLocale = locale; document.documentElement.setAttribute('data-locale', locale); document.documentElement.setAttribute('lang', locale); localStorage.setItem('locale', locale); // Update all text updateAllText(); // Update locale select dropdown (if visible) updateLocaleSelect(); // Remove loading class and show content document.body.classList.remove('loading-translations'); document.body.classList.add('translations-loaded'); } // Change locale from dropdown function changeLocale() { const select = document.getElementById('locale-select'); const newLocale = select.value; if (newLocale && newLocale !== currentLocale) { localStorage.setItem('locale', newLocale); setLocale(newLocale); } } // Update locale select dropdown function updateLocaleSelect() { const select = document.getElementById('locale-select'); if (select) { select.value = currentLocale; } } // Update all text on page function updateAllText() { // Update all elements with data-i18n attribute document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); // Update all elements with data-i18n-placeholder attribute document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); el.placeholder = t(key); }); // Update all elements with data-i18n-title attribute 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 // Update playback state updatePlaybackState(currentState); // Update connection status const connected = ws && ws.readyState === WebSocket.OPEN; updateConnectionStatus(connected); // Re-apply last media status if available if (lastStatus) { const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle; document.getElementById('source').textContent = lastStatus.source || t('player.unknown_source'); } // Reload tables and quick access to get translated content 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); } } let ws = null; let reconnectTimeout = null; let pingInterval = null; let wsReconnectAttempts = 0; let currentState = 'idle'; let currentDuration = 0; let currentPosition = 0; let isUserAdjustingVolume = false; let volumeUpdateTimer = null; // Timer for throttling volume updates let scripts = []; let lastStatus = null; // Store last status for locale switching let lastArtworkKey = null; // Track artwork identity to skip redundant loads let currentArtworkBlobUrl = null; // Track current blob URL for safe revocation // Dialog dirty state tracking let scriptFormDirty = false; let callbackFormDirty = false; // Position interpolation let lastPositionUpdate = 0; let lastPositionValue = 0; let interpolationInterval = null; function setupProgressDrag(bar, fill) { let dragging = false; function getPercent(clientX) { const rect = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); } function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; } function handleStart(clientX) { if (currentDuration <= 0) return; dragging = true; bar.classList.add('dragging'); updatePreview(getPercent(clientX)); } function handleMove(clientX) { if (!dragging) return; updatePreview(getPercent(clientX)); } function handleEnd(clientX) { if (!dragging) return; dragging = false; bar.classList.remove('dragging'); const percent = getPercent(clientX); seek(percent * currentDuration); } // Mouse events bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); }); document.addEventListener('mousemove', (e) => { handleMove(e.clientX); }); document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); }); // Touch events bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true }); document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); }); document.addEventListener('touchend', (e) => { if (dragging) { const touch = e.changedTouches[0]; handleEnd(touch.clientX); } }); // Simple click (mousedown + mouseup without move) bar.addEventListener('click', (e) => { if (currentDuration > 0) { seek(getPercent(e.clientX) * currentDuration); } }); } // Initialize on page load window.addEventListener('DOMContentLoaded', async () => { // Cache DOM references cacheDom(); // Initialize theme and accent color initTheme(); initAccentColor(); // Initialize vinyl mode applyVinylMode(); // Initialize locale (async - loads JSON file) await initLocale(); // Load version from health endpoint fetchVersion(); const token = localStorage.getItem('media_server_token'); if (token) { connectWebSocket(token); loadScripts(); loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); } else { showAuthForm(); } // Shared volume slider setup (avoids duplicate handler code) function setupVolumeSlider(sliderId) { const slider = document.getElementById(sliderId); slider.addEventListener('input', (e) => { isUserAdjustingVolume = 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); volumeUpdateTimer = setTimeout(() => { setVolume(volume); volumeUpdateTimer = null; }, VOLUME_THROTTLE_MS); }); slider.addEventListener('change', (e) => { if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); volumeUpdateTimer = null; } const volume = parseInt(e.target.value); setVolume(volume); setTimeout(() => { isUserAdjustingVolume = 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', () => { scriptFormDirty = true; }); scriptForm.addEventListener('change', () => { scriptFormDirty = true; }); // Callback form dirty state tracking const callbackForm = document.getElementById('callbackForm'); callbackForm.addEventListener('input', () => { callbackFormDirty = true; }); callbackForm.addEventListener('change', () => { callbackFormDirty = true; }); // Script dialog backdrop click to close const scriptDialog = document.getElementById('scriptDialog'); scriptDialog.addEventListener('click', (e) => { // Check if click is on the backdrop (not the dialog content) if (e.target === scriptDialog) { closeScriptDialog(); } }); // Callback dialog backdrop click to close const callbackDialog = document.getElementById('callbackDialog'); callbackDialog.addEventListener('click', (e) => { // Check if click is on the backdrop (not the dialog content) 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); }); // 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', () => { linkFormDirty = true; }); linkForm.addEventListener('change', () => { linkFormDirty = true; }); // Initialize browser toolbar and load folders initBrowserToolbar(); if (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; } }); }); function showAuthForm(errorMessage = '') { const overlay = document.getElementById('auth-overlay'); overlay.classList.remove('hidden'); const errorEl = document.getElementById('auth-error'); if (errorMessage) { errorEl.textContent = errorMessage; errorEl.classList.add('visible'); } else { errorEl.classList.remove('visible'); } } function hideAuthForm() { document.getElementById('auth-overlay').classList.add('hidden'); } function authenticate() { const token = document.getElementById('token-input').value.trim(); if (!token) { showAuthForm(t('auth.required')); return; } localStorage.setItem('media_server_token', token); connectWebSocket(token); } function clearToken() { localStorage.removeItem('media_server_token'); if (ws) { ws.close(); } showAuthForm(t('auth.cleared')); } function connectWebSocket(token) { // Clear previous ping interval to prevent stacking if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('WebSocket connected'); wsReconnectAttempts = 0; updateConnectionStatus(true); hideConnectionBanner(); hideAuthForm(); loadScripts(); loadScriptsTable(); loadCallbacksTable(); loadLinksTable(); loadHeaderLinks(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'status' || msg.type === 'status_update') { updateUI(msg.data); } else if (msg.type === 'scripts_changed') { console.log('Scripts changed, reloading...'); loadScripts(); // Reload Quick Actions loadScriptsTable(); // Reload Script Management table } else if (msg.type === 'links_changed') { console.log('Links changed, reloading...'); loadHeaderLinks(); loadLinksTable(); displayQuickAccess(); } else if (msg.type === 'error') { console.error('WebSocket error:', msg.message); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); updateConnectionStatus(false); }; ws.onclose = (event) => { console.log('WebSocket closed:', event.code); updateConnectionStatus(false); stopPositionInterpolation(); if (event.code === 4001) { // Invalid token localStorage.removeItem('media_server_token'); showAuthForm(t('auth.invalid')); } else if (event.code !== 1000) { // Abnormal closure - attempt reconnect with exponential backoff wsReconnectAttempts++; if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) { const delay = Math.min( WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1), WS_BACKOFF_MAX_MS ); console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`); if (wsReconnectAttempts >= 3) { showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); } reconnectTimeout = setTimeout(() => { const savedToken = localStorage.getItem('media_server_token'); if (savedToken) { connectWebSocket(savedToken); } }, delay); } else { // Exhausted retries - show manual reconnect showConnectionBanner(t('connection.lost'), true); } } }; // Send keepalive ping pingInterval = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, WS_PING_INTERVAL_MS); } function updateConnectionStatus(connected) { if (connected) { dom.statusDot.classList.add('connected'); } else { dom.statusDot.classList.remove('connected'); } } function showConnectionBanner(message, showButton) { const banner = document.getElementById('connectionBanner'); const text = document.getElementById('connectionBannerText'); const btn = document.getElementById('connectionBannerBtn'); text.textContent = message; btn.style.display = showButton ? '' : 'none'; banner.classList.remove('hidden'); } function hideConnectionBanner() { const banner = document.getElementById('connectionBanner'); banner.classList.add('hidden'); } function manualReconnect() { const savedToken = localStorage.getItem('media_server_token'); if (savedToken) { wsReconnectAttempts = 0; hideConnectionBanner(); connectWebSocket(savedToken); } } function updateUI(status) { // Store status for locale switching lastStatus = status; // Update track info const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); dom.trackTitle.textContent = status.title || fallbackTitle; dom.artist.textContent = status.artist || ''; dom.album.textContent = status.album || ''; // Update mini player info dom.miniTrackTitle.textContent = status.title || fallbackTitle; dom.miniArtist.textContent = status.artist || ''; // Update state const previousState = currentState; currentState = status.state; updatePlaybackState(status.state); // Update album art (skip if same track to avoid redundant network requests) const artworkSource = status.album_art_url || null; const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`; if (artworkKey !== lastArtworkKey) { lastArtworkKey = artworkKey; const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E"; if (artworkSource) { // Fetch artwork with Authorization header (avoid token in URL) const token = localStorage.getItem('media_server_token'); fetch(`/api/media/artwork?_=${Date.now()}`, { headers: { 'Authorization': `Bearer ${token}` } }) .then(r => r.ok ? r.blob() : null) .then(blob => { if (!blob) return; const oldBlobUrl = currentArtworkBlobUrl; const url = URL.createObjectURL(blob); currentArtworkBlobUrl = url; dom.albumArt.src = url; dom.miniAlbumArt.src = url; if (dom.albumArtGlow) dom.albumArtGlow.src = url; // Revoke old blob URL after a delay to let pending loads finish if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); }) .catch(err => console.error('Artwork fetch failed:', err)); } else { if (currentArtworkBlobUrl) { URL.revokeObjectURL(currentArtworkBlobUrl); currentArtworkBlobUrl = null; } dom.albumArt.src = placeholderArt; dom.miniAlbumArt.src = placeholderArt; if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow; } } // Update progress if (status.duration && status.position !== null) { currentDuration = status.duration; currentPosition = status.position; // Track position update for interpolation lastPositionUpdate = Date.now(); lastPositionValue = status.position; updateProgress(status.position, status.duration); } // Update volume if (!isUserAdjustingVolume) { dom.volumeSlider.value = status.volume; dom.volumeDisplay.textContent = `${status.volume}%`; dom.miniVolumeSlider.value = status.volume; dom.miniVolumeDisplay.textContent = `${status.volume}%`; } // Update mute state updateMuteIcon(status.muted); // Update source dom.source.textContent = status.source || t('player.unknown_source'); // Enable/disable controls based on state const hasMedia = status.state !== 'idle'; dom.btnPlayPause.disabled = !hasMedia; dom.btnNext.disabled = !hasMedia; dom.btnPrevious.disabled = !hasMedia; dom.miniBtnPlayPause.disabled = !hasMedia; // Start/stop position interpolation based on playback state if (status.state === 'playing' && previousState !== 'playing') { startPositionInterpolation(); } else if (status.state !== 'playing' && previousState === 'playing') { stopPositionInterpolation(); } } function updatePlaybackState(state) { currentPlayState = state; switch(state) { case 'playing': dom.playbackState.textContent = t('state.playing'); dom.stateIcon.innerHTML = SVG_PLAY; dom.playPauseIcon.innerHTML = SVG_PAUSE; dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE; break; case 'paused': dom.playbackState.textContent = t('state.paused'); dom.stateIcon.innerHTML = SVG_PAUSE; dom.playPauseIcon.innerHTML = SVG_PLAY; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; break; case 'stopped': dom.playbackState.textContent = t('state.stopped'); dom.stateIcon.innerHTML = SVG_STOP; dom.playPauseIcon.innerHTML = SVG_PLAY; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; break; default: dom.playbackState.textContent = t('state.idle'); dom.stateIcon.innerHTML = SVG_IDLE; dom.playPauseIcon.innerHTML = SVG_PLAY; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; } updateVinylSpin(); } function updateProgress(position, duration) { const percent = (position / duration) * 100; const widthStr = `${percent}%`; const currentStr = formatTime(position); const totalStr = formatTime(duration); const posRound = Math.round(position); const durRound = Math.round(duration); dom.progressFill.style.width = widthStr; dom.currentTime.textContent = currentStr; dom.totalTime.textContent = totalStr; dom.progressBar.dataset.duration = duration; dom.progressBar.setAttribute('aria-valuenow', posRound); dom.progressBar.setAttribute('aria-valuemax', durRound); dom.miniProgressFill.style.width = widthStr; dom.miniCurrentTime.textContent = currentStr; dom.miniTotalTime.textContent = totalStr; if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); const miniBar = document.getElementById('mini-progress-bar'); miniBar.setAttribute('aria-valuenow', posRound); miniBar.setAttribute('aria-valuemax', durRound); } function startPositionInterpolation() { // Clear any existing interval if (interpolationInterval) { clearInterval(interpolationInterval); } // Update position every 100ms for smooth animation interpolationInterval = setInterval(() => { if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) { const elapsed = (Date.now() - lastPositionUpdate) / 1000; const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); updateProgress(interpolatedPosition, currentDuration); } }, POSITION_INTERPOLATION_MS); } function stopPositionInterpolation() { if (interpolationInterval) { clearInterval(interpolationInterval); interpolationInterval = null; } } function updateMuteIcon(muted) { const path = muted ? SVG_MUTED : SVG_UNMUTED; dom.muteIcon.innerHTML = path; dom.miniMuteIcon.innerHTML = path; } 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')}`; } // 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; // Use WebSocket for low-latency volume updates 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 }); } // Scripts functionality async function loadScripts() { const token = localStorage.getItem('media_server_token'); try { const response = await fetch('/api/scripts/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { scripts = await response.json(); displayQuickAccess(); } } catch (error) { console.error('Error loading scripts:', error); } } let _quickAccessGen = 0; async function displayQuickAccess() { const gen = ++_quickAccessGen; const grid = document.getElementById('scripts-grid'); // Build everything into a fragment before touching the DOM const fragment = document.createDocumentFragment(); const hasScripts = scripts.length > 0; let hasLinks = false; // Render script buttons scripts.forEach(script => { const button = document.createElement('button'); button.className = 'script-btn'; button.onclick = () => executeScript(script.name, button); if (script.icon) { const iconEl = document.createElement('div'); iconEl.className = 'script-icon'; iconEl.setAttribute('data-mdi-icon', script.icon); button.appendChild(iconEl); } const label = document.createElement('div'); label.className = 'script-label'; label.textContent = script.label || script.name; button.appendChild(label); if (script.description) { const description = document.createElement('div'); description.className = 'script-description'; description.textContent = script.description; button.appendChild(description); } fragment.appendChild(button); }); // Fetch link cards try { const token = localStorage.getItem('media_server_token'); if (token) { const response = await fetch('/api/links/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (gen !== _quickAccessGen) return; // stale call, discard if (response.ok) { const links = await response.json(); hasLinks = links.length > 0; links.forEach(link => { const card = document.createElement('a'); card.className = 'script-btn link-card'; card.href = link.url; card.target = '_blank'; card.rel = 'noopener noreferrer'; if (link.icon) { const iconEl = document.createElement('div'); iconEl.className = 'script-icon'; iconEl.setAttribute('data-mdi-icon', link.icon); card.appendChild(iconEl); } const label = document.createElement('div'); label.className = 'script-label'; label.textContent = link.label || link.name; card.appendChild(label); if (link.description) { const desc = document.createElement('div'); desc.className = 'script-description'; desc.textContent = link.description; card.appendChild(desc); } fragment.appendChild(card); }); } } } catch (e) { if (gen !== _quickAccessGen) return; console.warn('Failed to load links for quick access:', e); } // Show empty state if nothing if (!hasScripts && !hasLinks) { const empty = document.createElement('div'); empty.className = 'scripts-empty empty-state-illustration'; empty.innerHTML = `

${t('quick_access.no_items')}

`; fragment.prepend(empty); } // Replace grid content atomically grid.innerHTML = ''; grid.appendChild(fragment); // Resolve MDI icons resolveMdiIcons(grid); } async function executeScript(scriptName, buttonElement) { const token = localStorage.getItem('media_server_token'); // Add executing state buttonElement.classList.add('executing'); try { const response = await fetch(`/api/scripts/execute/${scriptName}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ args: [] }) }); const result = await response.json(); if (response.ok && result.success) { showToast(`${scriptName} executed successfully`, 'success'); } else { showToast(`Failed to execute ${scriptName}`, 'error'); } } catch (error) { console.error(`Error executing script ${scriptName}:`, error); showToast(`Error executing ${scriptName}`, 'error'); } finally { // Remove executing state buttonElement.classList.remove('executing'); } } 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); // Trigger reflow then show requestAnimationFrame(() => { toast.classList.add('show'); }); setTimeout(() => { toast.classList.remove('show'); toast.addEventListener('transitionend', () => toast.remove(), { once: true }); // Fallback removal if transitionend doesn't fire setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500); }, TOAST_DURATION_MS); } 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); dialog.close(); } 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(); }); } // Script Management Functions let _loadScriptsPromise = null; async function loadScriptsTable() { if (_loadScriptsPromise) return _loadScriptsPromise; _loadScriptsPromise = _loadScriptsTableImpl(); _loadScriptsPromise.finally(() => { _loadScriptsPromise = null; }); return _loadScriptsPromise; } async function _loadScriptsTableImpl() { const token = localStorage.getItem('media_server_token'); const tbody = document.getElementById('scriptsTableBody'); try { const response = await fetch('/api/scripts/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch scripts'); } const scriptsList = await response.json(); if (scriptsList.length === 0) { tbody.innerHTML = '

' + t('scripts.empty') + '

'; return; } tbody.innerHTML = scriptsList.map(script => ` ${script.icon ? `` : ''}${escapeHtml(script.name)} ${escapeHtml(script.label || script.name)} ${escapeHtml(script.command || 'N/A')} ${script.timeout}s
`).join(''); resolveMdiIcons(tbody); } catch (error) { console.error('Error loading scripts:', error); tbody.innerHTML = 'Failed to load scripts'; } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function showAddScriptDialog() { const dialog = document.getElementById('scriptDialog'); const form = document.getElementById('scriptForm'); const title = document.getElementById('dialogTitle'); // Reset form form.reset(); document.getElementById('scriptOriginalName').value = ''; document.getElementById('scriptIsEdit').value = 'false'; document.getElementById('scriptName').disabled = false; document.getElementById('scriptIconPreview').innerHTML = ''; title.textContent = t('scripts.dialog.add'); // Reset dirty state scriptFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } async function showEditScriptDialog(scriptName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('scriptDialog'); const title = document.getElementById('dialogTitle'); try { // Fetch current script details const response = await fetch('/api/scripts/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch script details'); } const scriptsList = await response.json(); const script = scriptsList.find(s => s.name === scriptName); if (!script) { showToast('Script not found', 'error'); return; } // Populate form document.getElementById('scriptOriginalName').value = scriptName; document.getElementById('scriptIsEdit').value = 'true'; document.getElementById('scriptName').value = scriptName; document.getElementById('scriptName').disabled = true; // Can't change name document.getElementById('scriptLabel').value = script.label || ''; document.getElementById('scriptCommand').value = script.command || ''; document.getElementById('scriptDescription').value = script.description || ''; document.getElementById('scriptIcon').value = script.icon || ''; document.getElementById('scriptTimeout').value = script.timeout || 30; // Update icon preview const preview = document.getElementById('scriptIconPreview'); if (script.icon) { fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; }); } else { preview.innerHTML = ''; } title.textContent = t('scripts.dialog.edit'); // Reset dirty state scriptFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } catch (error) { console.error('Error loading script for edit:', error); showToast('Failed to load script details', 'error'); } } async function closeScriptDialog() { // Check if form has unsaved changes if (scriptFormDirty) { if (!await showConfirm(t('scripts.confirm.unsaved'))) { return; // User cancelled, don't close } } const dialog = document.getElementById('scriptDialog'); scriptFormDirty = false; // Reset dirty state dialog.close(); document.body.classList.remove('dialog-open'); } async function saveScript(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; const token = localStorage.getItem('media_server_token'); const isEdit = document.getElementById('scriptIsEdit').value === 'true'; const scriptName = isEdit ? document.getElementById('scriptOriginalName').value : document.getElementById('scriptName').value; const data = { command: document.getElementById('scriptCommand').value, label: document.getElementById('scriptLabel').value || null, description: document.getElementById('scriptDescription').value || '', icon: document.getElementById('scriptIcon').value || null, timeout: parseInt(document.getElementById('scriptTimeout').value) || 30, shell: true }; const endpoint = isEdit ? `/api/scripts/update/${scriptName}` : `/api/scripts/create/${scriptName}`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok && result.success) { showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); scriptFormDirty = false; closeScriptDialog(); } else { showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); } } catch (error) { console.error('Error saving script:', error); showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); } finally { if (submitBtn) submitBtn.disabled = false; } } async function deleteScriptConfirm(scriptName) { if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) { return; } const token = localStorage.getItem('media_server_token'); try { const response = await fetch(`/api/scripts/delete/${scriptName}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const result = await response.json(); if (response.ok && result.success) { showToast('Script deleted successfully', 'success'); // Don't reload manually - WebSocket will trigger it } else { showToast(result.detail || 'Failed to delete script', 'error'); } } catch (error) { console.error('Error deleting script:', error); showToast('Error deleting script', 'error'); } } // Callback Management Functions let _loadCallbacksPromise = null; async function loadCallbacksTable() { if (_loadCallbacksPromise) return _loadCallbacksPromise; _loadCallbacksPromise = _loadCallbacksTableImpl(); _loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; }); return _loadCallbacksPromise; } async function _loadCallbacksTableImpl() { const token = localStorage.getItem('media_server_token'); const tbody = document.getElementById('callbacksTableBody'); try { const response = await fetch('/api/callbacks/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch callbacks'); } const callbacksList = await response.json(); if (callbacksList.length === 0) { tbody.innerHTML = '

' + t('callbacks.empty') + '

'; return; } tbody.innerHTML = callbacksList.map(callback => ` ${escapeHtml(callback.name)} ${escapeHtml(callback.command)} ${callback.timeout}s
`).join(''); } catch (error) { console.error('Error loading callbacks:', error); tbody.innerHTML = 'Failed to load callbacks'; } } function showAddCallbackDialog() { const dialog = document.getElementById('callbackDialog'); const form = document.getElementById('callbackForm'); const title = document.getElementById('callbackDialogTitle'); // Reset form form.reset(); document.getElementById('callbackIsEdit').value = 'false'; document.getElementById('callbackName').disabled = false; title.textContent = t('callbacks.dialog.add'); // Reset dirty state callbackFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } async function showEditCallbackDialog(callbackName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('callbackDialog'); const title = document.getElementById('callbackDialogTitle'); try { // Fetch current callback details const response = await fetch('/api/callbacks/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch callback details'); } const callbacksList = await response.json(); const callback = callbacksList.find(c => c.name === callbackName); if (!callback) { showToast('Callback not found', 'error'); return; } // Populate form document.getElementById('callbackIsEdit').value = 'true'; document.getElementById('callbackName').value = callbackName; document.getElementById('callbackName').disabled = true; // Can't change event name document.getElementById('callbackCommand').value = callback.command; document.getElementById('callbackTimeout').value = callback.timeout; document.getElementById('callbackWorkingDir').value = callback.working_dir || ''; title.textContent = t('callbacks.dialog.edit'); // Reset dirty state callbackFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } catch (error) { console.error('Error loading callback for edit:', error); showToast('Failed to load callback details', 'error'); } } async function closeCallbackDialog() { // Check if form has unsaved changes if (callbackFormDirty) { if (!await showConfirm(t('callbacks.confirm.unsaved'))) { return; // User cancelled, don't close } } const dialog = document.getElementById('callbackDialog'); callbackFormDirty = false; // Reset dirty state dialog.close(); document.body.classList.remove('dialog-open'); } async function saveCallback(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; const token = localStorage.getItem('media_server_token'); const isEdit = document.getElementById('callbackIsEdit').value === 'true'; const callbackName = document.getElementById('callbackName').value; const data = { command: document.getElementById('callbackCommand').value, timeout: parseInt(document.getElementById('callbackTimeout').value) || 30, working_dir: document.getElementById('callbackWorkingDir').value || null, shell: true }; const endpoint = isEdit ? `/api/callbacks/update/${callbackName}` : `/api/callbacks/create/${callbackName}`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok && result.success) { showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); callbackFormDirty = false; closeCallbackDialog(); loadCallbacksTable(); } else { showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error'); } } catch (error) { console.error('Error saving callback:', error); showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error'); } finally { if (submitBtn) submitBtn.disabled = false; } } async function deleteCallbackConfirm(callbackName) { if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) { return; } const token = localStorage.getItem('media_server_token'); try { const response = await fetch(`/api/callbacks/delete/${callbackName}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const result = await response.json(); if (response.ok && result.success) { showToast('Callback deleted successfully', 'success'); loadCallbacksTable(); } else { showToast(result.detail || 'Failed to delete callback', 'error'); } } catch (error) { console.error('Error deleting callback:', error); showToast('Error deleting callback', 'error'); } } // Execution Result Dialog Functions function closeExecutionDialog() { const dialog = document.getElementById('executionDialog'); dialog.close(); document.body.classList.remove('dialog-open'); } function showExecutionResult(name, result, type = 'script') { const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); const statusDiv = document.getElementById('executionStatus'); const outputSection = document.getElementById('outputSection'); const errorSection = document.getElementById('errorSection'); const outputPre = document.getElementById('executionOutput'); const errorPre = document.getElementById('executionError'); // Set title title.textContent = `Execution Result: ${name}`; // Build status display const success = result.success && result.exit_code === 0; const statusClass = success ? 'success' : 'error'; const statusText = success ? 'Success' : 'Failed'; statusDiv.innerHTML = `
${statusText}
${result.exit_code !== undefined ? result.exit_code : 'N/A'}
${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}
`; // Always show output section outputSection.style.display = 'block'; if (result.stdout && result.stdout.trim()) { outputPre.textContent = result.stdout; } else { outputPre.textContent = '(no output)'; outputPre.style.fontStyle = 'italic'; outputPre.style.color = 'var(--text-secondary)'; } // Show error output if present if (result.stderr && result.stderr.trim()) { errorSection.style.display = 'block'; errorPre.textContent = result.stderr; errorPre.style.fontStyle = 'normal'; errorPre.style.color = 'var(--error)'; } else if (!success && result.error) { errorSection.style.display = 'block'; errorPre.textContent = result.error; errorPre.style.fontStyle = 'normal'; errorPre.style.color = 'var(--error)'; } else { errorSection.style.display = 'none'; } dialog.showModal(); } async function executeScriptDebug(scriptName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); const statusDiv = document.getElementById('executionStatus'); // Show dialog with loading state title.textContent = `Executing: ${scriptName}`; statusDiv.innerHTML = `
Running...
`; document.getElementById('outputSection').style.display = 'none'; document.getElementById('errorSection').style.display = 'none'; document.body.classList.add('dialog-open'); dialog.showModal(); try { const response = await fetch(`/api/scripts/execute/${scriptName}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ args: [] }) }); const result = await response.json(); if (response.ok) { showExecutionResult(scriptName, result, 'script'); } else { showExecutionResult(scriptName, { success: false, exit_code: -1, error: result.detail || 'Execution failed', stderr: result.detail || 'Unknown error' }, 'script'); } } catch (error) { console.error(`Error executing script ${scriptName}:`, error); showExecutionResult(scriptName, { success: false, exit_code: -1, error: error.message, stderr: `Network error: ${error.message}` }, 'script'); } } async function executeCallbackDebug(callbackName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); const statusDiv = document.getElementById('executionStatus'); // Show dialog with loading state title.textContent = `Executing: ${callbackName}`; statusDiv.innerHTML = `
Running...
`; document.getElementById('outputSection').style.display = 'none'; document.getElementById('errorSection').style.display = 'none'; document.body.classList.add('dialog-open'); dialog.showModal(); try { // For callbacks, we'll execute them directly via the callback endpoint // We need to trigger the callback as if the event occurred const response = await fetch(`/api/callbacks/execute/${callbackName}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); const result = await response.json(); if (response.ok) { showExecutionResult(callbackName, result, 'callback'); } else { showExecutionResult(callbackName, { success: false, exit_code: -1, error: result.detail || 'Execution failed', stderr: result.detail || 'Unknown error' }, 'callback'); } } catch (error) { console.error(`Error executing callback ${callbackName}:`, error); showExecutionResult(callbackName, { success: false, exit_code: -1, error: error.message, stderr: `Network error: ${error.message}` }, 'callback'); } } // ======================================== // Media Browser Functionality // ======================================== // Browser state let currentFolderId = null; let currentPath = ''; let currentOffset = 0; let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100; let totalItems = 0; let mediaFolders = {}; let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let cachedItems = null; let browserSearchTerm = ''; let browserSearchTimer = null; const thumbnailCache = new Map(); const THUMBNAIL_CACHE_MAX = 200; // Load media folders on page load async function loadMediaFolders() { try { const token = localStorage.getItem('media_server_token'); if (!token) { console.error('No API token found'); return; } const response = await fetch('/api/browser/folders', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error('Failed to load folders'); mediaFolders = await response.json(); // Load last browsed path or show root folder list loadLastBrowserPath(); } catch (error) { console.error('Error loading media folders:', error); showToast(t('browser.error_loading_folders'), 'error'); } } function showRootFolders() { currentFolderId = ''; currentPath = ''; currentOffset = 0; cachedItems = null; // Hide search at root level showBrowserSearch(false); // Render breadcrumb with just "Home" (not clickable at root) const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ''; const root = document.createElement('span'); root.className = 'breadcrumb-item breadcrumb-home'; root.innerHTML = ''; breadcrumb.appendChild(root); // Hide play all button and pagination document.getElementById('playAllBtn').style.display = 'none'; document.getElementById('browserPagination').style.display = 'none'; // Render folders as grid cards const container = document.getElementById('browserGrid'); revokeBlobUrls(container); if (viewMode === 'list') { container.className = 'browser-list'; } else if (viewMode === 'compact') { container.className = 'browser-grid browser-grid-compact'; } else { container.className = 'browser-grid'; } container.innerHTML = ''; Object.entries(mediaFolders).forEach(([id, folder]) => { if (!folder.enabled) return; if (viewMode === 'list') { const row = document.createElement('div'); row.className = 'browser-list-item'; row.onclick = () => { currentFolderId = id; browsePath(id, ''); }; row.innerHTML = `
📁
${folder.label}
`; container.appendChild(row); } else { const card = document.createElement('div'); card.className = 'browser-item'; card.onclick = () => { currentFolderId = id; browsePath(id, ''); }; card.innerHTML = `
📁
${folder.label}
`; container.appendChild(card); } }); } async function browsePath(folderId, path, offset = 0, nocache = false) { // Clear search when navigating showBrowserSearch(false); try { const token = localStorage.getItem('media_server_token'); if (!token) { console.error('No API token found'); return; } // Show loading spinner const container = document.getElementById('browserGrid'); container.className = 'browser-grid'; container.innerHTML = '
'; const encodedPath = encodeURIComponent(path); let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`; if (nocache) url += '&nocache=true'; const response = await fetch( url, { headers: { 'Authorization': `Bearer ${token}` } } ); if (!response.ok) { let errorMsg = 'Failed to browse path'; if (response.status === 503) { const errorData = await response.json().catch(() => ({})); errorMsg = errorData.detail || 'Folder is temporarily unavailable (network share not accessible)'; } throw new Error(errorMsg); } const data = await response.json(); currentPath = data.current_path; currentOffset = offset; totalItems = data.total; cachedItems = data.items; renderBreadcrumbs(data.current_path, data.parent_path); renderBrowserItems(cachedItems); renderPagination(); // Show search bar when inside a folder showBrowserSearch(true); // Show/hide Play All button based on whether media items exist const hasMedia = data.items.some(item => item.is_media); document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none'; // Save last path saveLastBrowserPath(folderId, currentPath); } catch (error) { console.error('Error browsing path:', error); const errorMsg = error.message || t('browser.error_loading'); showToast(errorMsg, 'error'); clearBrowserGrid(); } } function renderBreadcrumbs(currentPath, parentPath) { const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ''; const parts = (currentPath || '').split('/').filter(p => p); let path = '/'; // Home link (back to folder list) const home = document.createElement('span'); home.className = 'breadcrumb-item breadcrumb-home'; home.innerHTML = ''; home.onclick = () => showRootFolders(); breadcrumb.appendChild(home); // Separator + Folder name const sep = document.createElement('span'); sep.className = 'breadcrumb-separator'; sep.textContent = '›'; breadcrumb.appendChild(sep); const folderItem = document.createElement('span'); folderItem.className = 'breadcrumb-item'; folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root'; if (parts.length > 0) { folderItem.onclick = () => browsePath(currentFolderId, ''); } breadcrumb.appendChild(folderItem); // Path parts parts.forEach((part, index) => { // Separator const separator = document.createElement('span'); separator.className = 'breadcrumb-separator'; separator.textContent = '›'; breadcrumb.appendChild(separator); // Part path += (path === '/' ? '' : '/') + part; const item = document.createElement('span'); item.className = 'breadcrumb-item'; item.textContent = part; const itemPath = path; item.onclick = () => browsePath(currentFolderId, itemPath); breadcrumb.appendChild(item); }); } function revokeBlobUrls(container) { const cachedUrls = new Set(thumbnailCache.values()); container.querySelectorAll('img[src^="blob:"]').forEach(img => { // Don't revoke URLs managed by the thumbnail cache if (!cachedUrls.has(img.src)) { URL.revokeObjectURL(img.src); } }); } function renderBrowserItems(items) { const container = document.getElementById('browserGrid'); revokeBlobUrls(container); // Switch container class based on view mode if (viewMode === 'list') { container.className = 'browser-list'; renderBrowserList(items, container); } else if (viewMode === 'compact') { container.className = 'browser-grid browser-grid-compact'; renderBrowserGrid(items, container); } else { container.className = 'browser-grid'; renderBrowserGrid(items, container); } } function renderBrowserList(items, container) { container.innerHTML = ''; if (!items || items.length === 0) { container.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`; return; } items.forEach(item => { const row = document.createElement('div'); row.className = 'browser-list-item'; row.dataset.name = item.name; row.dataset.type = item.type; // Icon (small) with play overlay const icon = document.createElement('div'); icon.className = 'browser-list-icon'; if (item.is_media && item.type === 'audio') { const thumbnail = document.createElement('img'); thumbnail.className = 'browser-list-thumbnail loading'; thumbnail.alt = item.name; icon.appendChild(thumbnail); loadThumbnail(thumbnail, item.name); } else { icon.textContent = getFileIcon(item.type); } if (item.is_media) { const overlay = document.createElement('div'); overlay.className = 'browser-list-play-overlay'; overlay.innerHTML = ''; icon.appendChild(overlay); } row.appendChild(icon); // Name (show media title if available) const name = document.createElement('div'); name.className = 'browser-list-name'; name.textContent = item.title || item.name; row.appendChild(name); // Bitrate const br = document.createElement('div'); br.className = 'browser-list-bitrate'; br.textContent = formatBitrate(item.bitrate) || ''; row.appendChild(br); // Duration const dur = document.createElement('div'); dur.className = 'browser-list-duration'; dur.textContent = formatDuration(item.duration) || ''; row.appendChild(dur); // Size const size = document.createElement('div'); size.className = 'browser-list-size'; size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : ''; row.appendChild(size); // Download button if (item.is_media) { row.appendChild(createDownloadBtn(item.name, 'browser-list-download')); } else { row.appendChild(document.createElement('div')); } // Tooltip: show filename when title is displayed, or when name is ellipsed row.addEventListener('mouseenter', () => { if (item.title || name.scrollWidth > name.clientWidth) { row.title = item.name; } else { row.title = ''; } }); // Single click: play media or navigate folder row.onclick = () => { if (item.type === 'folder') { const newPath = currentPath === '/' ? '/' + item.name : currentPath + '/' + item.name; browsePath(currentFolderId, newPath); } else if (item.is_media) { playMediaFile(item.name); } }; container.appendChild(row); }); } function renderBrowserGrid(items, container) { container = container || document.getElementById('browserGrid'); container.innerHTML = ''; if (!items || items.length === 0) { container.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`; return; } items.forEach(item => { const div = document.createElement('div'); div.className = 'browser-item'; div.dataset.name = item.name; div.dataset.type = item.type; // Type badge if (item.type !== 'folder') { const typeBadge = document.createElement('div'); typeBadge.className = `browser-item-type ${item.type}`; typeBadge.innerHTML = getTypeBadgeIcon(item.type); div.appendChild(typeBadge); } // Thumbnail wrapper (for play overlay) const thumbWrapper = document.createElement('div'); thumbWrapper.className = 'browser-thumb-wrapper'; // Thumbnail or icon if (item.is_media && item.type === 'audio') { const thumbnail = document.createElement('img'); thumbnail.className = 'browser-thumbnail loading'; thumbnail.alt = item.name; thumbWrapper.appendChild(thumbnail); // Lazy load thumbnail loadThumbnail(thumbnail, item.name); } else { const icon = document.createElement('div'); icon.className = 'browser-icon'; icon.textContent = getFileIcon(item.type); thumbWrapper.appendChild(icon); } // Play overlay for media files if (item.is_media) { const overlay = document.createElement('div'); overlay.className = 'browser-play-overlay'; overlay.innerHTML = ''; thumbWrapper.appendChild(overlay); } div.appendChild(thumbWrapper); // Info const info = document.createElement('div'); info.className = 'browser-item-info'; const name = document.createElement('div'); name.className = 'browser-item-name'; name.textContent = item.title || item.name; info.appendChild(name); if (item.type !== 'folder') { const meta = document.createElement('div'); meta.className = 'browser-item-meta'; const parts = []; const duration = formatDuration(item.duration); if (duration) parts.push(duration); const bitrate = formatBitrate(item.bitrate); if (bitrate) parts.push(bitrate); if (item.size !== null) parts.push(formatFileSize(item.size)); meta.textContent = parts.join(' \u00B7 '); if (parts.length) info.appendChild(meta); } div.appendChild(info); // Tooltip: show filename when title is displayed, or when name is ellipsed div.addEventListener('mouseenter', () => { if (item.title || name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) { div.title = item.name; } else { div.title = ''; } }); // Single click: play media or navigate folder div.onclick = () => { if (item.type === 'folder') { const newPath = currentPath === '/' ? '/' + item.name : currentPath + '/' + item.name; browsePath(currentFolderId, newPath); } else if (item.is_media) { playMediaFile(item.name); } }; container.appendChild(div); }); } function getTypeBadgeIcon(type) { const svgs = { 'audio': '', 'video': '', }; return svgs[type] || ''; } function getFileIcon(type) { const icons = { 'folder': '📁', 'audio': '🎵', 'video': '🎬', 'other': '📄' }; return icons[type] || icons.other; } function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]; } function formatDuration(seconds) { if (seconds == null || seconds <= 0) return null; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } return `${m}:${String(s).padStart(2, '0')}`; } function formatBitrate(bps) { if (bps == null || bps <= 0) return null; return Math.round(bps / 1000) + ' kbps'; } async function loadThumbnail(imgElement, fileName) { try { const token = localStorage.getItem('media_server_token'); if (!token) { console.error('No API token found'); return; } const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); // Check cache first if (thumbnailCache.has(absolutePath)) { const cachedUrl = thumbnailCache.get(absolutePath); imgElement.onload = () => { imgElement.classList.remove('loading'); imgElement.classList.add('loaded'); }; imgElement.src = cachedUrl; return; } const encodedPath = encodeURIComponent(absolutePath); const response = await fetch( `/api/browser/thumbnail?path=${encodedPath}&size=medium`, { headers: { 'Authorization': `Bearer ${token}` } } ); if (response.status === 200) { const blob = await response.blob(); const url = URL.createObjectURL(blob); thumbnailCache.set(absolutePath, url); // Evict oldest entries when cache exceeds limit if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) { const oldest = thumbnailCache.keys().next().value; URL.revokeObjectURL(thumbnailCache.get(oldest)); thumbnailCache.delete(oldest); } // Wait for image to actually load before showing it imgElement.onload = () => { imgElement.classList.remove('loading'); imgElement.classList.add('loaded'); }; // Revoke previous blob URL if not managed by cache // (Cache is keyed by path, so check values) if (imgElement.src && imgElement.src.startsWith('blob:')) { let isCached = false; for (const url of thumbnailCache.values()) { if (url === imgElement.src) { isCached = true; break; } } if (!isCached) URL.revokeObjectURL(imgElement.src); } imgElement.src = url; } else { // Fallback to icon (204 = no thumbnail available) const parent = imgElement.parentElement; const isList = parent.classList.contains('browser-list-icon'); imgElement.remove(); if (isList) { parent.textContent = '🎵'; } else { const icon = document.createElement('div'); icon.className = 'browser-icon'; icon.textContent = '🎵'; parent.insertBefore(icon, parent.firstChild); } } } catch (error) { console.error('Error loading thumbnail:', error); imgElement.classList.remove('loading'); } } function buildAbsolutePath(folderId, relativePath, fileName) { const folderPath = mediaFolders[folderId].path; // Detect separator from folder path const sep = folderPath.includes('/') ? '/' : '\\'; const fullRelative = relativePath === '/' ? sep + fileName : relativePath.replace(/[/\\]/g, sep) + sep + fileName; return folderPath + fullRelative; } let playInProgress = false; async function playMediaFile(fileName) { if (playInProgress) return; playInProgress = true; try { const token = localStorage.getItem('media_server_token'); if (!token) { console.error('No API token found'); return; } const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const response = await fetch('/api/browser/play', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ path: absolutePath }) }); if (!response.ok) throw new Error('Failed to play file'); showToast(t('browser.play_success', { filename: fileName }), 'success'); } catch (error) { console.error('Error playing file:', error); showToast(t('browser.play_error'), 'error'); } finally { playInProgress = false; } } async function playAllFolder() { if (playInProgress) return; playInProgress = true; const btn = document.getElementById('playAllBtn'); if (btn) btn.disabled = true; try { const token = localStorage.getItem('media_server_token'); if (!token || !currentFolderId) return; const response = await fetch('/api/browser/play-folder', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id: currentFolderId, path: currentPath }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || 'Failed to play folder'); } const data = await response.json(); showToast(t('browser.play_all_success', { count: data.count }), 'success'); } catch (error) { console.error('Error playing folder:', error); showToast(t('browser.play_all_error'), 'error'); } finally { playInProgress = false; if (btn) btn.disabled = false; } } async function downloadFile(fileName, event) { if (event) event.stopPropagation(); const token = localStorage.getItem('media_server_token'); if (!token) return; const fullPath = currentPath === '/' ? '/' + fileName : currentPath + '/' + fileName; const encodedPath = encodeURIComponent(fullPath); try { const response = await fetch( `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`, { headers: { 'Authorization': `Bearer ${token}` } } ); if (!response.ok) throw new Error('Download failed'); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Download error:', error); showToast(t('browser.download_error'), 'error'); } } function createDownloadBtn(fileName, cssClass) { const btn = document.createElement('button'); btn.className = cssClass; btn.innerHTML = ''; btn.title = t('browser.download'); btn.onclick = (e) => downloadFile(fileName, e); return btn; } function renderPagination() { const pagination = document.getElementById('browserPagination'); const prevBtn = document.getElementById('prevPage'); const nextBtn = document.getElementById('nextPage'); const pageInput = document.getElementById('pageInput'); const pageTotal = document.getElementById('pageTotal'); const totalPages = Math.ceil(totalItems / itemsPerPage); const currentPage = Math.floor(currentOffset / itemsPerPage) + 1; if (totalPages <= 1) { pagination.style.display = 'none'; return; } pagination.style.display = 'flex'; pageInput.value = currentPage; pageInput.max = totalPages; pageTotal.textContent = `/ ${totalPages}`; prevBtn.disabled = currentPage === 1; nextBtn.disabled = currentPage === totalPages; } function previousPage() { if (currentOffset >= itemsPerPage) { browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage); } } function nextPage() { if (currentOffset + itemsPerPage < totalItems) { browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage); } } function refreshBrowser() { if (currentFolderId) { browsePath(currentFolderId, currentPath, currentOffset, true); } else { loadMediaFolders(); } } // Browser search function onBrowserSearch() { const input = document.getElementById('browserSearchInput'); const clearBtn = document.getElementById('browserSearchClear'); const term = input.value.trim(); clearBtn.style.display = term ? 'flex' : 'none'; // Debounce: wait 200ms after typing stops if (browserSearchTimer) clearTimeout(browserSearchTimer); browserSearchTimer = setTimeout(() => { browserSearchTerm = term.toLowerCase(); applyBrowserSearch(); }, SEARCH_DEBOUNCE_MS); } function clearBrowserSearch() { const input = document.getElementById('browserSearchInput'); input.value = ''; document.getElementById('browserSearchClear').style.display = 'none'; browserSearchTerm = ''; applyBrowserSearch(); input.focus(); } function applyBrowserSearch() { if (!cachedItems) return; if (!browserSearchTerm) { renderBrowserItems(cachedItems); return; } const filtered = cachedItems.filter(item => item.name.toLowerCase().includes(browserSearchTerm) || (item.title && item.title.toLowerCase().includes(browserSearchTerm)) ); renderBrowserItems(filtered); } function showBrowserSearch(visible) { document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none'; if (!visible) { document.getElementById('browserSearchInput').value = ''; document.getElementById('browserSearchClear').style.display = 'none'; browserSearchTerm = ''; } } function setViewMode(mode) { if (mode === viewMode) return; viewMode = mode; localStorage.setItem('mediaBrowser.viewMode', mode); // Update toggle buttons document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active')); const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn'; document.getElementById(btnId).classList.add('active'); // Re-render current view from cache (no network request) if (currentFolderId && cachedItems) { applyBrowserSearch(); } else { showRootFolders(); } } function onItemsPerPageChanged() { const select = document.getElementById('itemsPerPageSelect'); itemsPerPage = parseInt(select.value); localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage); // Reset to first page and reload if (currentFolderId) { currentOffset = 0; browsePath(currentFolderId, currentPath, 0); } } function goToPage() { const pageInput = document.getElementById('pageInput'); const totalPages = Math.ceil(totalItems / itemsPerPage); let page = parseInt(pageInput.value); if (isNaN(page) || page < 1) page = 1; if (page > totalPages) page = totalPages; pageInput.value = page; const newOffset = (page - 1) * itemsPerPage; if (newOffset !== currentOffset) { browsePath(currentFolderId, currentPath, newOffset); } } function initBrowserToolbar() { // Restore view mode const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; viewMode = savedViewMode; document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active')); const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn'; document.getElementById(btnId).classList.add('active'); // Restore items per page const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage'); if (savedItemsPerPage) { itemsPerPage = parseInt(savedItemsPerPage); document.getElementById('itemsPerPageSelect').value = savedItemsPerPage; } } function clearBrowserGrid() { const grid = document.getElementById('browserGrid'); grid.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}
`; document.getElementById('breadcrumb').innerHTML = ''; document.getElementById('browserPagination').style.display = 'none'; document.getElementById('playAllBtn').style.display = 'none'; } // LocalStorage for last path function saveLastBrowserPath(folderId, path) { try { localStorage.setItem('mediaBrowser.lastFolderId', folderId); localStorage.setItem('mediaBrowser.lastPath', path); } catch (e) { console.error('Failed to save last browser path:', e); } } function loadLastBrowserPath() { try { const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId'); const lastPath = localStorage.getItem('mediaBrowser.lastPath'); if (lastFolderId && mediaFolders[lastFolderId]) { currentFolderId = lastFolderId; browsePath(lastFolderId, lastPath || ''); } else { showRootFolders(); } } catch (e) { console.error('Failed to load last browser path:', e); showRootFolders(); } } // Folder Management function showManageFoldersDialog() { // TODO: Implement folder management UI // For now, show a simple alert showToast(t('browser.manage_folders_hint'), 'info'); } function closeFolderDialog() { document.getElementById('folderDialog').close(); } async function saveFolder(event) { event.preventDefault(); // TODO: Implement folder save functionality closeFolderDialog(); } // ============================================================ // Display Brightness & Power Control // ============================================================ let displayBrightnessTimers = {}; const DISPLAY_THROTTLE_MS = 50; async function loadDisplayMonitors() { const token = localStorage.getItem('media_server_token'); if (!token) return; const container = document.getElementById('displayMonitors'); if (!container) return; try { const response = await fetch('/api/display/monitors?refresh=true', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { container.innerHTML = `

Failed to load monitors

`; return; } const monitors = await response.json(); if (monitors.length === 0) { container.innerHTML = `

No monitors detected

`; return; } container.innerHTML = ''; monitors.forEach(monitor => { const card = document.createElement('div'); card.className = 'display-monitor-card'; card.id = `monitor-card-${monitor.id}`; const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0; const brightnessDisabled = monitor.brightness === null ? 'disabled' : ''; let powerBtn = ''; if (monitor.power_supported) { powerBtn = ` `; } const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' · '); const detailsHtml = details ? `${details}` : ''; const primaryBadge = monitor.is_primary ? `${t('display.primary')}` : ''; card.innerHTML = `
${monitor.name}${primaryBadge} ${detailsHtml}
${powerBtn}
${brightnessValue}%
`; container.appendChild(card); }); } catch (e) { console.error('Failed to load display monitors:', e); } } function onDisplayBrightnessInput(monitorId, value) { const label = document.getElementById(`brightness-val-${monitorId}`); if (label) label.textContent = `${value}%`; if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]); displayBrightnessTimers[monitorId] = setTimeout(() => { sendDisplayBrightness(monitorId, parseInt(value)); displayBrightnessTimers[monitorId] = null; }, DISPLAY_THROTTLE_MS); } function onDisplayBrightnessChange(monitorId, value) { if (displayBrightnessTimers[monitorId]) { clearTimeout(displayBrightnessTimers[monitorId]); displayBrightnessTimers[monitorId] = null; } sendDisplayBrightness(monitorId, parseInt(value)); } async function sendDisplayBrightness(monitorId, brightness) { const token = localStorage.getItem('media_server_token'); try { await fetch(`/api/display/brightness/${monitorId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ brightness }) }); } catch (e) { console.error('Failed to set brightness:', e); } } async function toggleDisplayPower(monitorId, monitorName) { const btn = document.getElementById(`power-btn-${monitorId}`); const isOn = btn && btn.classList.contains('on'); const newState = !isOn; const token = localStorage.getItem('media_server_token'); try { const response = await fetch(`/api/display/power/${monitorId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ on: newState }) }); const data = await response.json(); if (data.success) { if (btn) { btn.classList.toggle('on', newState); btn.classList.toggle('off', !newState); btn.title = newState ? t('display.power_off') : t('display.power_on'); } showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success'); } else { showToast('Failed to change monitor power', 'error'); } } catch (e) { console.error('Failed to set display power:', e); showToast('Failed to change monitor power', 'error'); } } // ============================================================ // Header Quick Links // ============================================================ const mdiIconCache = {}; async function fetchMdiIcon(iconName) { // Parse "mdi:icon-name" → "icon-name" 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; return svg; } } catch (e) { console.warn('Failed to fetch MDI icon:', name, e); } // Fallback: generic link icon return ''; } // Resolve all data-mdi-icon placeholders in a container 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); } })); } // Debounced icon preview updater 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); // Re-check value hasn't changed during fetch if (input.value.trim() === value) { preview.innerHTML = svg; } }, 400); }); } async function loadHeaderLinks() { const token = localStorage.getItem('media_server_token'); if (!token) return; const container = document.getElementById('headerLinks'); if (!container) return; try { const response = await fetch('/api/links/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) return; const links = await response.json(); container.innerHTML = ''; for (const link of links) { const a = document.createElement('a'); a.href = link.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.className = 'header-link'; a.title = link.label || link.url; const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link'); a.innerHTML = iconSvg; container.appendChild(a); } } catch (e) { console.warn('Failed to load header links:', e); } } // ============================================================ // Links Management // ============================================================ let _loadLinksPromise = null; let linkFormDirty = false; async function loadLinksTable() { if (_loadLinksPromise) return _loadLinksPromise; _loadLinksPromise = _loadLinksTableImpl(); _loadLinksPromise.finally(() => { _loadLinksPromise = null; }); return _loadLinksPromise; } async function _loadLinksTableImpl() { const token = localStorage.getItem('media_server_token'); const tbody = document.getElementById('linksTableBody'); try { const response = await fetch('/api/links/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch links'); } const linksList = await response.json(); if (linksList.length === 0) { tbody.innerHTML = '

' + t('links.empty') + '

'; return; } tbody.innerHTML = linksList.map(link => ` ${escapeHtml(link.name)} ${escapeHtml(link.url)} ${escapeHtml(link.label || '')}
`).join(''); resolveMdiIcons(tbody); } catch (error) { console.error('Error loading links:', error); tbody.innerHTML = 'Failed to load links'; } } function showAddLinkDialog() { const dialog = document.getElementById('linkDialog'); const form = document.getElementById('linkForm'); const title = document.getElementById('linkDialogTitle'); form.reset(); document.getElementById('linkOriginalName').value = ''; document.getElementById('linkIsEdit').value = 'false'; document.getElementById('linkName').disabled = false; document.getElementById('linkIconPreview').innerHTML = ''; title.textContent = t('links.dialog.add'); linkFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } async function showEditLinkDialog(linkName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('linkDialog'); const title = document.getElementById('linkDialogTitle'); try { const response = await fetch('/api/links/list', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch link details'); } const linksList = await response.json(); const link = linksList.find(l => l.name === linkName); if (!link) { showToast(t('links.msg.not_found'), 'error'); return; } document.getElementById('linkOriginalName').value = linkName; document.getElementById('linkIsEdit').value = 'true'; document.getElementById('linkName').value = linkName; document.getElementById('linkName').disabled = true; document.getElementById('linkUrl').value = link.url; document.getElementById('linkIcon').value = link.icon || ''; document.getElementById('linkLabel').value = link.label || ''; document.getElementById('linkDescription').value = link.description || ''; // Update icon preview const preview = document.getElementById('linkIconPreview'); if (link.icon) { fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; }); } else { preview.innerHTML = ''; } title.textContent = t('links.dialog.edit'); linkFormDirty = false; document.body.classList.add('dialog-open'); dialog.showModal(); } catch (error) { console.error('Error loading link for edit:', error); showToast(t('links.msg.load_failed'), 'error'); } } async function closeLinkDialog() { if (linkFormDirty) { if (!await showConfirm(t('links.confirm.unsaved'))) { return; } } const dialog = document.getElementById('linkDialog'); linkFormDirty = false; dialog.close(); document.body.classList.remove('dialog-open'); } async function saveLink(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; const token = localStorage.getItem('media_server_token'); const isEdit = document.getElementById('linkIsEdit').value === 'true'; const linkName = isEdit ? document.getElementById('linkOriginalName').value : document.getElementById('linkName').value; const data = { url: document.getElementById('linkUrl').value, icon: document.getElementById('linkIcon').value || 'mdi:link', label: document.getElementById('linkLabel').value || '', description: document.getElementById('linkDescription').value || '' }; const endpoint = isEdit ? `/api/links/update/${linkName}` : `/api/links/create/${linkName}`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok && result.success) { showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success'); linkFormDirty = false; closeLinkDialog(); } else { showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error'); } } catch (error) { console.error('Error saving link:', error); showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error'); } finally { if (submitBtn) submitBtn.disabled = false; } } async function deleteLinkConfirm(linkName) { if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) { return; } const token = localStorage.getItem('media_server_token'); try { const response = await fetch(`/api/links/delete/${linkName}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); const result = await response.json(); if (response.ok && result.success) { showToast(t('links.msg.deleted'), 'success'); } else { showToast(result.detail || t('links.msg.delete_failed'), 'error'); } } catch (error) { console.error('Error deleting link:', error); showToast(t('links.msg.delete_failed'), 'error'); } }