// 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 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 document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); if (activeBtn) activeBtn.classList.add('active'); // 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); } // 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 to get translated content const token = localStorage.getItem('media_server_token'); if (token) { loadScriptsTable(); loadCallbacksTable(); } } 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 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 // Dialog dirty state tracking let scriptFormDirty = false; let callbackFormDirty = false; // Position interpolation let lastPositionUpdate = 0; let lastPositionValue = 0; let interpolationInterval = null; // Initialize on page load window.addEventListener('DOMContentLoaded', async () => { // Initialize theme initTheme(); // 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(); } else { showAuthForm(); } // Volume slider event const volumeSlider = document.getElementById('volume-slider'); volumeSlider.addEventListener('input', (e) => { isUserAdjustingVolume = true; const volume = parseInt(e.target.value); document.getElementById('volume-display').textContent = `${volume}%`; // Throttle volume updates while dragging (update every 16ms via WebSocket) if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); } volumeUpdateTimer = setTimeout(() => { setVolume(volume); volumeUpdateTimer = null; }, 16); }); volumeSlider.addEventListener('change', (e) => { // Clear any pending throttled update if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); volumeUpdateTimer = null; } // Send final volume update immediately const volume = parseInt(e.target.value); setVolume(volume); setTimeout(() => { isUserAdjustingVolume = false; }, 500); }); // Restore saved tab const savedTab = localStorage.getItem('activeTab') || 'player'; switchTab(savedTab); // Mini Player: Intersection Observer to show/hide when main player scrolls out of view const playerContainer = document.querySelector('.player-container'); const miniPlayer = document.getElementById('mini-player'); const observerOptions = { root: null, // viewport threshold: 0.1, // trigger when 10% visible rootMargin: '0px' }; const observerCallback = (entries) => { entries.forEach(entry => { // Only use scroll-based logic when on the player tab if (activeTab !== 'player') return; setMiniPlayerVisible(!entry.isIntersecting); }); }; const observer = new IntersectionObserver(observerCallback, observerOptions); observer.observe(playerContainer); // Mini player progress bar click to seek const miniProgressBar = document.getElementById('mini-progress-bar'); miniProgressBar.addEventListener('click', (e) => { const rect = miniProgressBar.getBoundingClientRect(); const percent = (e.clientX - rect.left) / rect.width; const position = percent * currentDuration; seek(position); }); // Mini player volume slider const miniVolumeSlider = document.getElementById('mini-volume-slider'); miniVolumeSlider.addEventListener('input', (e) => { isUserAdjustingVolume = true; const volume = parseInt(e.target.value); document.getElementById('mini-volume-display').textContent = `${volume}%`; document.getElementById('volume-display').textContent = `${volume}%`; document.getElementById('volume-slider').value = volume; // Throttle volume updates while dragging (update every 16ms via WebSocket) if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); } volumeUpdateTimer = setTimeout(() => { setVolume(volume); volumeUpdateTimer = null; }, 16); }); miniVolumeSlider.addEventListener('change', (e) => { // Clear any pending throttled update if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); volumeUpdateTimer = null; } // Send final volume update immediately const volume = parseInt(e.target.value); setVolume(volume); setTimeout(() => { isUserAdjustingVolume = false; }, 500); }); // Progress bar click to seek const progressBar = document.getElementById('progress-bar'); progressBar.addEventListener('click', (e) => { if (currentDuration > 0) { const rect = progressBar.getBoundingClientRect(); const x = e.clientX - rect.left; const percent = x / rect.width; const seekPos = percent * currentDuration; seek(seekPos); } }); // 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(); } }); }); 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) { 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'); updateConnectionStatus(true); hideAuthForm(); loadScripts(); loadScriptsTable(); loadCallbacksTable(); }; 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 === '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 reconnectTimeout = setTimeout(() => { const savedToken = localStorage.getItem('media_server_token'); if (savedToken) { console.log('Attempting to reconnect...'); connectWebSocket(savedToken); } }, 3000); } }; // Send keepalive ping every 30 seconds setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); } function updateConnectionStatus(connected) { const dot = document.getElementById('status-dot'); if (connected) { dot.classList.add('connected'); } else { dot.classList.remove('connected'); } } 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'); document.getElementById('track-title').textContent = status.title || fallbackTitle; document.getElementById('artist').textContent = status.artist || ''; document.getElementById('album').textContent = status.album || ''; // Update mini player info document.getElementById('mini-track-title').textContent = status.title || fallbackTitle; document.getElementById('mini-artist').textContent = status.artist || ''; // Update state const previousState = currentState; currentState = status.state; updatePlaybackState(status.state); // Update album art const artImg = document.getElementById('album-art'); const miniArtImg = document.getElementById('mini-album-art'); const artworkUrl = status.album_art_url ? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}` : "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"; artImg.src = artworkUrl; miniArtImg.src = artworkUrl; // 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) { document.getElementById('volume-slider').value = status.volume; document.getElementById('volume-display').textContent = `${status.volume}%`; document.getElementById('mini-volume-slider').value = status.volume; document.getElementById('mini-volume-display').textContent = `${status.volume}%`; } // Update mute state updateMuteIcon(status.muted); // Update source document.getElementById('source').textContent = status.source || t('player.unknown_source'); // Enable/disable controls based on state const hasMedia = status.state !== 'idle'; document.getElementById('btn-play-pause').disabled = !hasMedia; document.getElementById('btn-next').disabled = !hasMedia; document.getElementById('btn-previous').disabled = !hasMedia; document.getElementById('mini-btn-play-pause').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) { const stateText = document.getElementById('playback-state'); const stateIcon = document.getElementById('state-icon'); const playPauseIcon = document.getElementById('play-pause-icon'); const miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); switch(state) { case 'playing': stateText.textContent = t('state.playing'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; miniPlayPauseIcon.innerHTML = ''; break; case 'paused': stateText.textContent = t('state.paused'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; miniPlayPauseIcon.innerHTML = ''; break; case 'stopped': stateText.textContent = t('state.stopped'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; miniPlayPauseIcon.innerHTML = ''; break; default: stateText.textContent = t('state.idle'); stateIcon.innerHTML = ''; playPauseIcon.innerHTML = ''; miniPlayPauseIcon.innerHTML = ''; } } function updateProgress(position, duration) { const percent = (position / duration) * 100; document.getElementById('progress-fill').style.width = `${percent}%`; document.getElementById('current-time').textContent = formatTime(position); document.getElementById('total-time').textContent = formatTime(duration); document.getElementById('progress-bar').dataset.duration = duration; // Update mini player progress document.getElementById('mini-progress-fill').style.width = `${percent}%`; document.getElementById('mini-current-time').textContent = formatTime(position); document.getElementById('mini-total-time').textContent = formatTime(duration); } 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) { // Calculate elapsed time since last position update const elapsed = (Date.now() - lastPositionUpdate) / 1000; const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); // Update UI with interpolated position updateProgress(interpolatedPosition, currentDuration); } }, 100); } function stopPositionInterpolation() { if (interpolationInterval) { clearInterval(interpolationInterval); interpolationInterval = null; } } function updateMuteIcon(muted) { const muteIcon = document.getElementById('mute-icon'); const miniMuteIcon = document.getElementById('mini-mute-icon'); const mutedPath = ''; const unmutedPath = ''; if (muted) { muteIcon.innerHTML = mutedPath; miniMuteIcon.innerHTML = mutedPath; } else { muteIcon.innerHTML = unmutedPath; miniMuteIcon.innerHTML = unmutedPath; } } 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) { console.error(`Command ${endpoint} failed:`, response.status); } } catch (error) { console.error(`Error sending command ${endpoint}:`, error); } } function togglePlayPause() { if (currentState === 'playing') { sendCommand('pause'); } else { sendCommand('play'); } } function nextTrack() { sendCommand('next'); } function previousTrack() { sendCommand('previous'); } function setVolume(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(); displayScripts(); } } catch (error) { console.error('Error loading scripts:', error); } } function displayScripts() { const container = document.getElementById('scripts-container'); const grid = document.getElementById('scripts-grid'); if (scripts.length === 0) { grid.innerHTML = `
${t('scripts.no_scripts')}
`; return; } grid.innerHTML = ''; scripts.forEach(script => { const button = document.createElement('button'); button.className = 'script-btn'; button.onclick = () => executeScript(script.name, button); 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); } grid.appendChild(button); }); } 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 toast = document.getElementById('toast'); toast.textContent = message; toast.className = `toast ${type} show`; setTimeout(() => { toast.classList.remove('show'); }, 3000); } // Script Management Functions async function loadScriptsTable() { 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 = 'No scripts configured. Click "Add Script" to create one.'; return; } tbody.innerHTML = scriptsList.map(script => ` ${script.name} ${script.label || script.name} ${escapeHtml(script.command || 'N/A')} ${script.timeout}s
`).join(''); } 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; 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; 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'); } } function closeScriptDialog() { // Check if form has unsaved changes if (scriptFormDirty) { if (!confirm(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 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; // Reset dirty state before closing closeScriptDialog(); // Don't reload manually - WebSocket will trigger it } 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'); } } async function deleteScriptConfirm(scriptName) { if (!confirm(`Are you sure you want to delete the script "${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 async function loadCallbacksTable() { 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 = 'No callbacks configured. Click "Add Callback" to create one.'; return; } tbody.innerHTML = callbacksList.map(callback => ` ${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'); } } function closeCallbackDialog() { // Check if form has unsaved changes if (callbackFormDirty) { if (!confirm(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 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; // Reset dirty state before closing 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'); } } async function deleteCallbackConfirm(callbackName) { if (!confirm(`Are you sure you want to delete the callback "${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; // 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'); 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) throw new Error('Failed to browse path'); 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); showToast(t('browser.error_loading'), '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 renderBrowserItems(items) { const container = document.getElementById('browserGrid'); // 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 = `
${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 const name = document.createElement('div'); name.className = 'browser-list-name'; name.textContent = 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 on row when name is ellipsed row.addEventListener('mouseenter', () => { if (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 = `
${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.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 on card when name is ellipsed div.addEventListener('mouseenter', () => { if (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 fullPath = currentPath === '/' ? '/' + fileName : currentPath + '/' + fileName; const encodedPath = encodeURIComponent( mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\') ); 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); // Wait for image to actually load before showing it imgElement.onload = () => { imgElement.classList.remove('loading'); imgElement.classList.add('loaded'); }; 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'); } } async function playMediaFile(fileName) { try { const token = localStorage.getItem('media_server_token'); if (!token) { console.error('No API token found'); return; } const fullPath = currentPath === '/' ? '/' + fileName : currentPath + '/' + fileName; const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\'); 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'); const data = await response.json(); showToast(t('browser.play_success', { filename: fileName }), 'success'); } catch (error) { console.error('Error playing file:', error); showToast(t('browser.play_error'), 'error'); } } async function playAllFolder() { 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'); } } 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); const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`; const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); } 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(); }, 200); } 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) ); 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 = `
${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(); } // Initialize browser on page load window.addEventListener('DOMContentLoaded', () => { initBrowserToolbar(); // Load media folders after authentication const token = localStorage.getItem('media_server_token'); if (token) { loadMediaFolders(); } });