diff --git a/media_server/static/index.html b/media_server/static/index.html index eb25792..8f54bd7 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -638,6 +638,13 @@ - + + + + + + + + diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js deleted file mode 100644 index 8099501..0000000 --- a/media_server/static/js/app.js +++ /dev/null @@ -1,3803 +0,0 @@ - // SVG path constants (avoid rebuilding innerHTML on every state update) - const SVG_PLAY = ''; - const SVG_PAUSE = ''; - const SVG_STOP = ''; - const SVG_IDLE = ''; - const SVG_MUTED = ''; - const SVG_UNMUTED = ''; - - // Empty state illustration SVGs - const EMPTY_SVG_FOLDER = ''; - const EMPTY_SVG_FILE = ''; - function emptyStateHtml(svgStr, text) { - return `
${svgStr}

${text}

`; - } - - // Media source registry: substring key → { name, icon } - const MEDIA_SOURCES = { - 'spotify': { - name: 'Spotify', - icon: '' - }, - 'yandex music': { - name: 'Yandex Music', - icon: '' - }, - 'яндекс музыка': { - name: 'Яндекс Музыка', - icon: '' - }, - 'chrome': { - name: 'Google Chrome', - icon: '' - }, - 'msedge': { - name: 'Microsoft Edge', - icon: '' - }, - 'firefox': { - name: 'Firefox', - icon: '' - }, - 'opera': { - name: 'Opera', - icon: '' - }, - 'brave': { - name: 'Brave', - icon: '' - }, - 'yandex': { - name: 'Yandex Browser', - icon: '' - }, - 'vlc': { - name: 'VLC', - icon: '' - }, - 'aimp': { - name: 'AIMP', - icon: '' - }, - 'foobar': { - name: 'foobar2000', - icon: '' - }, - 'music.ui': { - name: 'Groove Music', - icon: '' - }, - 'itunes': { - name: 'iTunes', - icon: '' - }, - 'apple music': { - name: 'Apple Music', - icon: '' - }, - 'deezer': { - name: 'Deezer', - icon: '' - }, - 'tidal': { - name: 'TIDAL', - icon: '' - }, - }; - - function resolveMediaSource(raw) { - if (!raw) return null; - const lower = raw.toLowerCase(); - for (const [key, info] of Object.entries(MEDIA_SOURCES)) { - if (lower.includes(key)) return info; - } - return { name: raw.replace(/\.exe$/i, ''), icon: null }; - } - - // Cached DOM references (populated once after DOMContentLoaded) - const dom = {}; - function cacheDom() { - dom.trackTitle = document.getElementById('track-title'); - dom.artist = document.getElementById('artist'); - dom.album = document.getElementById('album'); - dom.miniTrackTitle = document.getElementById('mini-track-title'); - dom.miniArtist = document.getElementById('mini-artist'); - dom.albumArt = document.getElementById('album-art'); - dom.albumArtGlow = document.getElementById('album-art-glow'); - dom.miniAlbumArt = document.getElementById('mini-album-art'); - dom.volumeSlider = document.getElementById('volume-slider'); - dom.volumeDisplay = document.getElementById('volume-display'); - dom.miniVolumeSlider = document.getElementById('mini-volume-slider'); - dom.miniVolumeDisplay = document.getElementById('mini-volume-display'); - dom.progressFill = document.getElementById('progress-fill'); - dom.currentTime = document.getElementById('current-time'); - dom.totalTime = document.getElementById('total-time'); - dom.progressBar = document.getElementById('progress-bar'); - dom.miniProgressFill = document.getElementById('mini-progress-fill'); - dom.miniCurrentTime = document.getElementById('mini-current-time'); - dom.miniTotalTime = document.getElementById('mini-total-time'); - dom.playbackState = document.getElementById('playback-state'); - dom.stateIcon = document.getElementById('state-icon'); - dom.playPauseIcon = document.getElementById('play-pause-icon'); - dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); - dom.muteIcon = document.getElementById('mute-icon'); - dom.miniMuteIcon = document.getElementById('mini-mute-icon'); - dom.statusDot = document.getElementById('status-dot'); - dom.source = document.getElementById('source'); - dom.sourceIcon = document.getElementById('sourceIcon'); - dom.btnPlayPause = document.getElementById('btn-play-pause'); - dom.btnNext = document.getElementById('btn-next'); - dom.btnPrevious = document.getElementById('btn-previous'); - dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause'); - dom.miniPlayer = document.getElementById('mini-player'); - } - - // Timing constants - const VOLUME_THROTTLE_MS = 16; - const POSITION_INTERPOLATION_MS = 100; - const SEARCH_DEBOUNCE_MS = 200; - const TOAST_DURATION_MS = 3000; - const WS_BACKOFF_BASE_MS = 3000; - const WS_BACKOFF_MAX_MS = 30000; - const WS_MAX_RECONNECT_ATTEMPTS = 20; - const WS_PING_INTERVAL_MS = 30000; - const VOLUME_RELEASE_DELAY_MS = 500; - - // 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 getVinylAngle() { - const art = document.getElementById('album-art'); - if (!art) return 0; - const st = getComputedStyle(art); - const tr = st.transform; - if (!tr || tr === 'none') return 0; - const m = tr.match(/matrix\((.+)\)/); - if (!m) return 0; - const vals = m[1].split(',').map(Number); - const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI)); - return ((angle % 360) + 360) % 360; - } - - function saveVinylAngle() { - if (!vinylMode) return; - localStorage.setItem('vinylAngle', getVinylAngle()); - } - - function restoreVinylAngle() { - const saved = localStorage.getItem('vinylAngle'); - if (saved) { - const art = document.getElementById('album-art'); - if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`); - } - } - - setInterval(saveVinylAngle, 2000); - window.addEventListener('beforeunload', saveVinylAngle); - - function toggleVinylMode() { - // Save current angle before turning off (must happen while still in vinyl mode) - if (vinylMode) saveVinylAngle(); - 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'); - restoreVinylAngle(); - updateVinylSpin(); - } else { - saveVinylAngle(); - 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'); - } - } - - // Audio Visualizer - let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; - let visualizerAvailable = false; - let visualizerCtx = null; - let visualizerAnimFrame = null; - let frequencyData = null; - let smoothedFrequencies = null; - const VISUALIZER_SMOOTHING = 0.65; - - async function checkVisualizerAvailability() { - try { - const token = localStorage.getItem('media_server_token'); - const resp = await fetch('/api/media/visualizer/status', { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (resp.ok) { - const data = await resp.json(); - visualizerAvailable = data.available && data.running; - } - } catch (e) { - visualizerAvailable = false; - } - const btn = document.getElementById('visualizerToggle'); - if (btn) btn.style.display = visualizerAvailable ? '' : 'none'; - } - - function toggleVisualizer() { - visualizerEnabled = !visualizerEnabled; - localStorage.setItem('visualizerEnabled', visualizerEnabled); - applyVisualizerMode(); - } - - function applyVisualizerMode() { - const container = document.querySelector('.album-art-container'); - const btn = document.getElementById('visualizerToggle'); - if (!container) return; - - if (visualizerEnabled && visualizerAvailable) { - container.classList.add('visualizer-active'); - if (btn) btn.classList.add('active'); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'enable_visualizer' })); - } - initVisualizerCanvas(); - startVisualizerRender(); - } else { - container.classList.remove('visualizer-active'); - if (btn) btn.classList.remove('active'); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'disable_visualizer' })); - } - stopVisualizerRender(); - } - } - - function initVisualizerCanvas() { - const canvas = document.getElementById('spectrogram-canvas'); - if (!canvas) return; - visualizerCtx = canvas.getContext('2d'); - canvas.width = 300; - canvas.height = 64; - } - - function startVisualizerRender() { - if (visualizerAnimFrame) return; - renderVisualizerFrame(); - } - - function stopVisualizerRender() { - if (visualizerAnimFrame) { - cancelAnimationFrame(visualizerAnimFrame); - visualizerAnimFrame = null; - } - const canvas = document.getElementById('spectrogram-canvas'); - if (visualizerCtx && canvas) { - visualizerCtx.clearRect(0, 0, canvas.width, canvas.height); - } - // Reset album art / vinyl pulse - const art = document.getElementById('album-art'); - if (art) { - art.style.transform = ''; - art.style.removeProperty('--vinyl-scale'); - } - const glow = document.getElementById('album-art-glow'); - if (glow) glow.style.opacity = ''; - frequencyData = null; - smoothedFrequencies = null; - } - - function renderVisualizerFrame() { - visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame); - - const canvas = document.getElementById('spectrogram-canvas'); - if (!frequencyData || !visualizerCtx || !canvas) return; - - const bins = frequencyData.frequencies; - const numBins = bins.length; - const w = canvas.width; - const h = canvas.height; - const gap = 2; - const barWidth = (w / numBins) - gap; - const accent = getComputedStyle(document.documentElement) - .getPropertyValue('--accent').trim(); - - // Smooth transitions - if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) { - smoothedFrequencies = new Array(numBins).fill(0); - } - for (let i = 0; i < numBins; i++) { - smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING - + bins[i] * (1 - VISUALIZER_SMOOTHING); - } - - visualizerCtx.clearRect(0, 0, w, h); - - for (let i = 0; i < numBins; i++) { - const barHeight = Math.max(1, smoothedFrequencies[i] * h); - const x = i * (barWidth + gap) + gap / 2; - const y = h - barHeight; - - const grad = visualizerCtx.createLinearGradient(x, y, x, h); - grad.addColorStop(0, accent); - grad.addColorStop(1, accent + '30'); - - visualizerCtx.fillStyle = grad; - visualizerCtx.beginPath(); - visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5); - visualizerCtx.fill(); - } - - // Album art / vinyl pulse based on bass energy - const bass = frequencyData.bass || 0; - const scale = 1 + bass * 0.03; - const art = document.getElementById('album-art'); - if (art) { - if (vinylMode) { - // Use CSS custom property so it composes with the rotation animation - art.style.setProperty('--vinyl-scale', scale); - } else { - art.style.transform = `scale(${scale})`; - } - } - const glow = document.getElementById('album-art-glow'); - if (glow) { - glow.style.opacity = (0.5 + bass * 0.3).toFixed(2); - } - } - - // Audio device selection - async function loadAudioDevices() { - const section = document.getElementById('audioDeviceSection'); - const select = document.getElementById('audioDeviceSelect'); - if (!section || !select) return; - - try { - const token = localStorage.getItem('media_server_token'); - - const [devicesResp, statusResp] = await Promise.all([ - fetch('/api/media/visualizer/devices', { - headers: { 'Authorization': `Bearer ${token}` } - }), - fetch('/api/media/visualizer/status', { - headers: { 'Authorization': `Bearer ${token}` } - }) - ]); - - if (!devicesResp.ok || !statusResp.ok) return; - - const devices = await devicesResp.json(); - const status = await statusResp.json(); - - if (!status.available && devices.length === 0) { - section.style.display = 'none'; - return; - } - - section.style.display = ''; - - // Populate dropdown (keep auto-detect as first option) - while (select.options.length > 1) select.remove(1); - for (const dev of devices) { - const opt = document.createElement('option'); - opt.value = dev.name; - opt.textContent = dev.name; - select.appendChild(opt); - } - - // Select current device - if (status.current_device) { - for (let i = 0; i < select.options.length; i++) { - if (select.options[i].value === status.current_device) { - select.selectedIndex = i; - break; - } - } - } - - updateAudioDeviceStatus(status); - } catch (e) { - section.style.display = 'none'; - } - } - - function updateAudioDeviceStatus(status) { - const el = document.getElementById('audioDeviceStatus'); - if (!el) return; - if (status.running) { - el.className = 'audio-device-status active'; - el.textContent = t('settings.audio.status_active'); - } else if (status.available) { - el.className = 'audio-device-status available'; - el.textContent = t('settings.audio.status_available'); - } else { - el.className = 'audio-device-status unavailable'; - el.textContent = t('settings.audio.status_unavailable'); - } - } - - async function onAudioDeviceChanged() { - const select = document.getElementById('audioDeviceSelect'); - if (!select) return; - - const deviceName = select.value || null; - const token = localStorage.getItem('media_server_token'); - - try { - const resp = await fetch('/api/media/visualizer/device', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ device_name: deviceName }) - }); - - if (resp.ok) { - const result = await resp.json(); - updateAudioDeviceStatus(result); - await checkVisualizerAvailability(); - if (visualizerEnabled) applyVisualizerMode(); - showToast(t('settings.audio.device_changed'), 'success'); - } else { - showToast(t('settings.audio.device_change_failed'), 'error'); - } - } catch (e) { - showToast(t('settings.audio.device_change_failed'), 'error'); - } - } - - // 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; - const initSrc = resolveMediaSource(lastStatus.source); - document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source'); - document.getElementById('sourceIcon').innerHTML = initSrc?.icon || ''; - } - - // 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 audio visualizer - checkVisualizerAvailability().then(() => { - if (visualizerEnabled && visualizerAvailable) { - applyVisualizerMode(); - } - }); - - // 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(); - loadAudioDevices(); - } 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(); - loadAudioDevices(); - // Re-enable visualizer subscription on reconnect - if (visualizerEnabled && visualizerAvailable) { - ws.send(JSON.stringify({ type: 'enable_visualizer' })); - } - }; - - 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 === 'audio_data') { - frequencyData = msg.data; - } 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 alt text for accessibility - const altText = status.title && status.artist - ? `${status.artist} – ${status.title}` - : status.title || t('player.no_media'); - dom.albumArt.alt = altText; - dom.miniAlbumArt.alt = altText; - - // 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 - const src = resolveMediaSource(status.source); - dom.source.textContent = src ? src.name : t('player.unknown_source'); - dom.sourceIcon.innerHTML = src?.icon || ''; - - // 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 -// ============================================================ - -// In-memory + localStorage cache for MDI icons (persists across reloads) -const mdiIconCache = (() => { - try { - return JSON.parse(localStorage.getItem('mdiIconCache') || '{}'); - } catch { return {}; } -})(); - -function _persistMdiCache() { - try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} -} - -async function fetchMdiIcon(iconName) { - // 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; - _persistMdiCache(); - 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'); - } -} - diff --git a/media_server/static/js/browser.js b/media_server/static/js/browser.js new file mode 100644 index 0000000..299010a --- /dev/null +++ b/media_server/static/js/browser.js @@ -0,0 +1,880 @@ +// ============================================================ +// Media Browser: Navigation, rendering, search, pagination +// ============================================================ + +// 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 = ` +
\u{1F4C1}
+
${folder.label}
+ `; + container.appendChild(row); + } else { + const card = document.createElement('div'); + card.className = 'browser-item'; + card.onclick = () => { + currentFolderId = id; + browsePath(id, ''); + }; + card.innerHTML = ` +
+
\u{1F4C1}
+
+
+
${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 = '\u203A'; + 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 = '\u203A'; + 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': '\u{1F4C1}', + 'audio': '\u{1F3B5}', + 'video': '\u{1F3AC}', + 'other': '\u{1F4C4}' + }; + 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 = '\u{1F3B5}'; + } else { + const icon = document.createElement('div'); + icon.className = 'browser-icon'; + icon.textContent = '\u{1F3B5}'; + 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(); +} diff --git a/media_server/static/js/callbacks.js b/media_server/static/js/callbacks.js new file mode 100644 index 0000000..729d9bc --- /dev/null +++ b/media_server/static/js/callbacks.js @@ -0,0 +1,209 @@ +// ============================================================ +// Callbacks: CRUD management +// ============================================================ + +let callbackFormDirty = false; + +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'); + + form.reset(); + document.getElementById('callbackIsEdit').value = 'false'; + document.getElementById('callbackName').disabled = false; + title.textContent = t('callbacks.dialog.add'); + + 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 { + 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; + } + + document.getElementById('callbackIsEdit').value = 'true'; + document.getElementById('callbackName').value = callbackName; + document.getElementById('callbackName').disabled = true; + 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'); + 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() { + if (callbackFormDirty) { + if (!await showConfirm(t('callbacks.confirm.unsaved'))) { + return; + } + } + + const dialog = document.getElementById('callbackDialog'); + callbackFormDirty = false; + 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'); + } +} diff --git a/media_server/static/js/core.js b/media_server/static/js/core.js new file mode 100644 index 0000000..13e594a --- /dev/null +++ b/media_server/static/js/core.js @@ -0,0 +1,495 @@ +// ============================================================ +// Core: Shared state, constants, utilities, i18n, API commands +// ============================================================ + +// SVG path constants (avoid rebuilding innerHTML on every state update) +const SVG_PLAY = ''; +const SVG_PAUSE = ''; +const SVG_STOP = ''; +const SVG_IDLE = ''; +const SVG_MUTED = ''; +const SVG_UNMUTED = ''; + +// Empty state illustration SVGs +const EMPTY_SVG_FOLDER = ''; +const EMPTY_SVG_FILE = ''; +function emptyStateHtml(svgStr, text) { + return `
${svgStr}

${text}

`; +} + +// Media source registry: substring key → { name, icon } +const MEDIA_SOURCES = { + 'spotify': { + name: 'Spotify', + icon: '' + }, + 'yandex music': { + name: 'Yandex Music', + icon: '' + }, + 'яндекс музыка': { + name: 'Яндекс Музыка', + icon: '' + }, + 'chrome': { + name: 'Google Chrome', + icon: '' + }, + 'msedge': { + name: 'Microsoft Edge', + icon: '' + }, + 'firefox': { + name: 'Firefox', + icon: '' + }, + 'opera': { + name: 'Opera', + icon: '' + }, + 'brave': { + name: 'Brave', + icon: '' + }, + 'yandex': { + name: 'Yandex Browser', + icon: '' + }, + 'vlc': { + name: 'VLC', + icon: '' + }, + 'aimp': { + name: 'AIMP', + icon: '' + }, + 'foobar': { + name: 'foobar2000', + icon: '' + }, + 'music.ui': { + name: 'Groove Music', + icon: '' + }, + 'itunes': { + name: 'iTunes', + icon: '' + }, + 'apple music': { + name: 'Apple Music', + icon: '' + }, + 'deezer': { + name: 'Deezer', + icon: '' + }, + 'tidal': { + name: 'TIDAL', + icon: '' + }, +}; + +function resolveMediaSource(raw) { + if (!raw) return null; + const lower = raw.toLowerCase(); + for (const [key, info] of Object.entries(MEDIA_SOURCES)) { + if (lower.includes(key)) return info; + } + return { name: raw.replace(/\.exe$/i, ''), icon: null }; +} + +// Cached DOM references (populated once after DOMContentLoaded) +const dom = {}; +function cacheDom() { + dom.trackTitle = document.getElementById('track-title'); + dom.artist = document.getElementById('artist'); + dom.album = document.getElementById('album'); + dom.miniTrackTitle = document.getElementById('mini-track-title'); + dom.miniArtist = document.getElementById('mini-artist'); + dom.albumArt = document.getElementById('album-art'); + dom.albumArtGlow = document.getElementById('album-art-glow'); + dom.miniAlbumArt = document.getElementById('mini-album-art'); + dom.volumeSlider = document.getElementById('volume-slider'); + dom.volumeDisplay = document.getElementById('volume-display'); + dom.miniVolumeSlider = document.getElementById('mini-volume-slider'); + dom.miniVolumeDisplay = document.getElementById('mini-volume-display'); + dom.progressFill = document.getElementById('progress-fill'); + dom.currentTime = document.getElementById('current-time'); + dom.totalTime = document.getElementById('total-time'); + dom.progressBar = document.getElementById('progress-bar'); + dom.miniProgressFill = document.getElementById('mini-progress-fill'); + dom.miniCurrentTime = document.getElementById('mini-current-time'); + dom.miniTotalTime = document.getElementById('mini-total-time'); + dom.playbackState = document.getElementById('playback-state'); + dom.stateIcon = document.getElementById('state-icon'); + dom.playPauseIcon = document.getElementById('play-pause-icon'); + dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); + dom.muteIcon = document.getElementById('mute-icon'); + dom.miniMuteIcon = document.getElementById('mini-mute-icon'); + dom.statusDot = document.getElementById('status-dot'); + dom.source = document.getElementById('source'); + dom.sourceIcon = document.getElementById('sourceIcon'); + dom.btnPlayPause = document.getElementById('btn-play-pause'); + dom.btnNext = document.getElementById('btn-next'); + dom.btnPrevious = document.getElementById('btn-previous'); + dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause'); + dom.miniPlayer = document.getElementById('mini-player'); +} + +// Timing constants +const VOLUME_THROTTLE_MS = 16; +const POSITION_INTERPOLATION_MS = 100; +const SEARCH_DEBOUNCE_MS = 200; +const TOAST_DURATION_MS = 3000; +const WS_BACKOFF_BASE_MS = 3000; +const WS_BACKOFF_MAX_MS = 30000; +const WS_MAX_RECONNECT_ATTEMPTS = 20; +const WS_PING_INTERVAL_MS = 30000; +const VOLUME_RELEASE_DELAY_MS = 500; + +// Shared state (accessed across multiple modules) +let ws = null; +let currentState = 'idle'; +let currentDuration = 0; +let currentPosition = 0; +let isUserAdjustingVolume = false; +let volumeUpdateTimer = null; +let scripts = []; +let lastStatus = null; +let currentPlayState = 'idle'; + +// ============================================================ +// Internationalization (i18n) +// ============================================================ + +let currentLocale = 'en'; +let translations = {}; +const supportedLocales = { + 'en': 'English', + 'ru': 'Русский' +}; + +// Minimal inline fallback for critical UI elements +const fallbackTranslations = { + 'app.title': 'Media Server', + 'auth.connect': 'Connect', + 'auth.placeholder': 'Enter API Token', + 'player.status.connected': 'Connected', + 'player.status.disconnected': 'Disconnected' +}; + +function t(key, params = {}) { + let text = translations[key] || fallbackTranslations[key] || key; + Object.keys(params).forEach(param => { + text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); + }); + return text; +} + +async function loadTranslations(locale) { + try { + const response = await fetch(`/static/locales/${locale}.json`); + if (!response.ok) { + throw new Error(`Failed to load ${locale}.json`); + } + return await response.json(); + } catch (error) { + console.error(`Error loading translations for ${locale}:`, error); + if (locale !== 'en') { + return await loadTranslations('en'); + } + return {}; + } +} + +function detectBrowserLocale() { + const browserLang = navigator.language || navigator.languages?.[0] || 'en'; + const langCode = browserLang.split('-')[0]; + return supportedLocales[langCode] ? langCode : 'en'; +} + +async function initLocale() { + const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); + await setLocale(savedLocale); +} + +async function setLocale(locale) { + if (!supportedLocales[locale]) { + locale = 'en'; + } + translations = await loadTranslations(locale); + currentLocale = locale; + document.documentElement.setAttribute('data-locale', locale); + document.documentElement.setAttribute('lang', locale); + localStorage.setItem('locale', locale); + updateAllText(); + updateLocaleSelect(); + document.body.classList.remove('loading-translations'); + document.body.classList.add('translations-loaded'); +} + +function changeLocale() { + const select = document.getElementById('locale-select'); + const newLocale = select.value; + if (newLocale && newLocale !== currentLocale) { + localStorage.setItem('locale', newLocale); + setLocale(newLocale); + } +} + +function updateLocaleSelect() { + const select = document.getElementById('locale-select'); + if (select) { + select.value = currentLocale; + } +} + +function updateAllText() { + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = t(key); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = t(key); + }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = t(key); + }); + + // Re-apply dynamic content with new translations + updatePlaybackState(currentState); + const connected = ws && ws.readyState === WebSocket.OPEN; + updateConnectionStatus(connected); + + if (lastStatus) { + const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); + document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle; + const initSrc = resolveMediaSource(lastStatus.source); + document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source'); + document.getElementById('sourceIcon').innerHTML = initSrc?.icon || ''; + } + + const token = localStorage.getItem('media_server_token'); + if (token) { + loadScriptsTable(); + loadCallbacksTable(); + loadLinksTable(); + displayQuickAccess(); + } + renderAccentSwatches(); +} + +async function fetchVersion() { + try { + const response = await fetch('/api/health'); + if (response.ok) { + const data = await response.json(); + const label = document.getElementById('version-label'); + if (data.version) { + label.textContent = `v${data.version}`; + } + } + } catch (error) { + console.error('Error fetching version:', error); + } +} + +// ============================================================ +// Shared Utilities +// ============================================================ + +function formatTime(seconds) { + if (!seconds || seconds < 0) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(message, type = 'success') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.add('show'); + }); + + setTimeout(() => { + toast.classList.remove('show'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500); + }, TOAST_DURATION_MS); +} + +function 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(); + }); +} + +// ============================================================ +// API Commands +// ============================================================ + +async function sendCommand(endpoint, body = null) { + const token = localStorage.getItem('media_server_token'); + + const options = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(`/api/media/${endpoint}`, options); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + console.error(`Command ${endpoint} failed:`, response.status); + showToast(data.detail || `Command failed: ${endpoint}`, 'error'); + } + } catch (error) { + console.error(`Error sending command ${endpoint}:`, error); + showToast(`Connection error: ${endpoint}`, 'error'); + } +} + +function togglePlayPause() { + if (currentState === 'playing') { + sendCommand('pause'); + } else { + sendCommand('play'); + } +} + +function nextTrack() { + sendCommand('next'); +} + +function previousTrack() { + sendCommand('previous'); +} + +let lastSentVolume = -1; +function setVolume(volume) { + if (volume === lastSentVolume) return; + lastSentVolume = volume; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'volume', volume: volume })); + } else { + sendCommand('volume', { volume: volume }); + } +} + +function toggleMute() { + sendCommand('mute'); +} + +function seek(position) { + sendCommand('seek', { position: position }); +} + +// ============================================================ +// MDI Icon System +// ============================================================ + +const mdiIconCache = (() => { + try { + return JSON.parse(localStorage.getItem('mdiIconCache') || '{}'); + } catch { return {}; } +})(); + +function _persistMdiCache() { + try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} +} + +async function fetchMdiIcon(iconName) { + const name = iconName.replace(/^mdi:/, ''); + if (mdiIconCache[name]) return mdiIconCache[name]; + + try { + const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`); + if (response.ok) { + const svg = await response.text(); + mdiIconCache[name] = svg; + _persistMdiCache(); + return svg; + } + } catch (e) { + console.warn('Failed to fetch MDI icon:', name, e); + } + + return ''; +} + +async function resolveMdiIcons(container) { + const els = container.querySelectorAll('[data-mdi-icon]'); + await Promise.all(Array.from(els).map(async (el) => { + const icon = el.dataset.mdiIcon; + if (icon) { + el.innerHTML = await fetchMdiIcon(icon); + } + })); +} + +function setupIconPreview(inputId, previewId) { + const input = document.getElementById(inputId); + const preview = document.getElementById(previewId); + if (!input || !preview) return; + + let debounceTimer = null; + + input.addEventListener('input', () => { + clearTimeout(debounceTimer); + const value = input.value.trim(); + + if (!value) { + preview.innerHTML = ''; + return; + } + + debounceTimer = setTimeout(async () => { + const svg = await fetchMdiIcon(value); + if (input.value.trim() === value) { + preview.innerHTML = svg; + } + }, 400); + }); +} diff --git a/media_server/static/js/links.js b/media_server/static/js/links.js new file mode 100644 index 0000000..0500e0a --- /dev/null +++ b/media_server/static/js/links.js @@ -0,0 +1,414 @@ +// ============================================================ +// 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(' \u00B7 '); + 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 +// ============================================================ + +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'); + } +} diff --git a/media_server/static/js/main.js b/media_server/static/js/main.js new file mode 100644 index 0000000..13e63b4 --- /dev/null +++ b/media_server/static/js/main.js @@ -0,0 +1,286 @@ +// ============================================================ +// Main: Initialization orchestrator (loaded last) +// ============================================================ + +window.addEventListener('DOMContentLoaded', async () => { + // Cache DOM references + cacheDom(); + + // Initialize theme and accent color + initTheme(); + initAccentColor(); + + // Initialize vinyl mode + applyVinylMode(); + + // Initialize audio visualizer + checkVisualizerAvailability().then(() => { + if (visualizerEnabled && visualizerAvailable) { + applyVisualizerMode(); + } + }); + + // 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(); + loadAudioDevices(); + } 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; + } + }); +}); diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js new file mode 100644 index 0000000..b7ed756 --- /dev/null +++ b/media_server/static/js/player.js @@ -0,0 +1,734 @@ +// ============================================================ +// Player: Tabs, theme, accent, vinyl, visualizer, UI updates +// ============================================================ + +// 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) { + indicator.offsetHeight; + indicator.style.transition = ''; + } +} + +function switchTab(tabName) { + activeTab = tabName; + + document.querySelectorAll('[data-tab-content]').forEach(el => { + el.classList.remove('active'); + el.style.display = ''; + }); + + const target = document.querySelector(`[data-tab-content="${tabName}"]`); + if (target) { + target.classList.add('active'); + } + + 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); + } + + if (tabName === 'display') { + loadDisplayMonitors(); + } + + localStorage.setItem('activeTab', tabName); + + if (tabName !== 'player') { + setMiniPlayerVisible(true); + } else { + 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'; + +function getVinylAngle() { + const art = document.getElementById('album-art'); + if (!art) return 0; + const st = getComputedStyle(art); + const tr = st.transform; + if (!tr || tr === 'none') return 0; + const m = tr.match(/matrix\((.+)\)/); + if (!m) return 0; + const vals = m[1].split(',').map(Number); + const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI)); + return ((angle % 360) + 360) % 360; +} + +function saveVinylAngle() { + if (!vinylMode) return; + localStorage.setItem('vinylAngle', getVinylAngle()); +} + +function restoreVinylAngle() { + const saved = localStorage.getItem('vinylAngle'); + if (saved) { + const art = document.getElementById('album-art'); + if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`); + } +} + +setInterval(saveVinylAngle, 2000); +window.addEventListener('beforeunload', saveVinylAngle); + +function toggleVinylMode() { + if (vinylMode) saveVinylAngle(); + 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'); + restoreVinylAngle(); + updateVinylSpin(); + } else { + saveVinylAngle(); + 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'); + } +} + +// Audio Visualizer +let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; +let visualizerAvailable = false; +let visualizerCtx = null; +let visualizerAnimFrame = null; +let frequencyData = null; +let smoothedFrequencies = null; +const VISUALIZER_SMOOTHING = 0.65; + +async function checkVisualizerAvailability() { + try { + const token = localStorage.getItem('media_server_token'); + const resp = await fetch('/api/media/visualizer/status', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (resp.ok) { + const data = await resp.json(); + visualizerAvailable = data.available; + } + } catch (e) { + visualizerAvailable = false; + } + const btn = document.getElementById('visualizerToggle'); + if (btn) btn.style.display = visualizerAvailable ? '' : 'none'; +} + +function toggleVisualizer() { + visualizerEnabled = !visualizerEnabled; + localStorage.setItem('visualizerEnabled', visualizerEnabled); + applyVisualizerMode(); +} + +function applyVisualizerMode() { + const container = document.querySelector('.album-art-container'); + const btn = document.getElementById('visualizerToggle'); + if (!container) return; + + if (visualizerEnabled && visualizerAvailable) { + container.classList.add('visualizer-active'); + if (btn) btn.classList.add('active'); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'enable_visualizer' })); + } + initVisualizerCanvas(); + startVisualizerRender(); + } else { + container.classList.remove('visualizer-active'); + if (btn) btn.classList.remove('active'); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'disable_visualizer' })); + } + stopVisualizerRender(); + } + + // Sync the audio device status badge with the new capture state + updateAudioDeviceStatus({ + running: visualizerEnabled && visualizerAvailable, + available: visualizerAvailable + }); +} + +function initVisualizerCanvas() { + const canvas = document.getElementById('spectrogram-canvas'); + if (!canvas) return; + visualizerCtx = canvas.getContext('2d'); + canvas.width = 300; + canvas.height = 64; +} + +function startVisualizerRender() { + if (visualizerAnimFrame) return; + renderVisualizerFrame(); +} + +function stopVisualizerRender() { + if (visualizerAnimFrame) { + cancelAnimationFrame(visualizerAnimFrame); + visualizerAnimFrame = null; + } + const canvas = document.getElementById('spectrogram-canvas'); + if (visualizerCtx && canvas) { + visualizerCtx.clearRect(0, 0, canvas.width, canvas.height); + } + const art = document.getElementById('album-art'); + if (art) { + art.style.transform = ''; + art.style.removeProperty('--vinyl-scale'); + } + const glow = document.getElementById('album-art-glow'); + if (glow) glow.style.opacity = ''; + frequencyData = null; + smoothedFrequencies = null; +} + +function renderVisualizerFrame() { + visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame); + + const canvas = document.getElementById('spectrogram-canvas'); + if (!frequencyData || !visualizerCtx || !canvas) return; + + const bins = frequencyData.frequencies; + const numBins = bins.length; + const w = canvas.width; + const h = canvas.height; + const gap = 2; + const barWidth = (w / numBins) - gap; + const accent = getComputedStyle(document.documentElement) + .getPropertyValue('--accent').trim(); + + if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) { + smoothedFrequencies = new Array(numBins).fill(0); + } + for (let i = 0; i < numBins; i++) { + smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING + + bins[i] * (1 - VISUALIZER_SMOOTHING); + } + + visualizerCtx.clearRect(0, 0, w, h); + + for (let i = 0; i < numBins; i++) { + const barHeight = Math.max(1, smoothedFrequencies[i] * h); + const x = i * (barWidth + gap) + gap / 2; + const y = h - barHeight; + + const grad = visualizerCtx.createLinearGradient(x, y, x, h); + grad.addColorStop(0, accent); + grad.addColorStop(1, accent + '30'); + + visualizerCtx.fillStyle = grad; + visualizerCtx.beginPath(); + visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5); + visualizerCtx.fill(); + } + + const bass = frequencyData.bass || 0; + const scale = 1 + bass * 0.03; + const art = document.getElementById('album-art'); + if (art) { + if (vinylMode) { + art.style.setProperty('--vinyl-scale', scale); + } else { + art.style.transform = `scale(${scale})`; + } + } + const glow = document.getElementById('album-art-glow'); + if (glow) { + glow.style.opacity = (0.5 + bass * 0.3).toFixed(2); + } +} + +// Audio device selection +async function loadAudioDevices() { + const section = document.getElementById('audioDeviceSection'); + const select = document.getElementById('audioDeviceSelect'); + if (!section || !select) return; + + try { + const token = localStorage.getItem('media_server_token'); + + const [devicesResp, statusResp] = await Promise.all([ + fetch('/api/media/visualizer/devices', { + headers: { 'Authorization': `Bearer ${token}` } + }), + fetch('/api/media/visualizer/status', { + headers: { 'Authorization': `Bearer ${token}` } + }) + ]); + + if (!devicesResp.ok || !statusResp.ok) return; + + const devices = await devicesResp.json(); + const status = await statusResp.json(); + + if (!status.available && devices.length === 0) { + section.style.display = 'none'; + return; + } + + section.style.display = ''; + + while (select.options.length > 1) select.remove(1); + for (const dev of devices) { + const opt = document.createElement('option'); + opt.value = dev.name; + opt.textContent = dev.name; + select.appendChild(opt); + } + + if (status.current_device) { + for (let i = 0; i < select.options.length; i++) { + if (select.options[i].value === status.current_device) { + select.selectedIndex = i; + break; + } + } + } + + updateAudioDeviceStatus(status); + } catch (e) { + section.style.display = 'none'; + } +} + +function updateAudioDeviceStatus(status) { + const el = document.getElementById('audioDeviceStatus'); + if (!el) return; + // Badge reflects local visualizer state (capture is on-demand per subscriber) + if (visualizerEnabled && status.available) { + el.className = 'audio-device-status active'; + el.textContent = t('settings.audio.status_active'); + } else if (status.available) { + el.className = 'audio-device-status available'; + el.textContent = t('settings.audio.status_available'); + } else { + el.className = 'audio-device-status unavailable'; + el.textContent = t('settings.audio.status_unavailable'); + } +} + +async function onAudioDeviceChanged() { + const select = document.getElementById('audioDeviceSelect'); + if (!select) return; + + const deviceName = select.value || null; + const token = localStorage.getItem('media_server_token'); + + try { + const resp = await fetch('/api/media/visualizer/device', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ device_name: deviceName }) + }); + + if (resp.ok) { + const result = await resp.json(); + updateAudioDeviceStatus(result); + await checkVisualizerAvailability(); + if (visualizerEnabled) applyVisualizerMode(); + showToast(t('settings.audio.device_changed'), 'success'); + } else { + showToast(t('settings.audio.device_change_failed'), 'error'); + } + } catch (e) { + showToast(t('settings.audio.device_change_failed'), 'error'); + } +} + +// ============================================================ +// UI State Updates +// ============================================================ + +let lastArtworkKey = null; +let currentArtworkBlobUrl = null; +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); + } + + bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); }); + document.addEventListener('mousemove', (e) => { handleMove(e.clientX); }); + document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); }); + + 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); + } + }); + + bar.addEventListener('click', (e) => { + if (currentDuration > 0) { + seek(getPercent(e.clientX) * currentDuration); + } + }); +} + +function updateUI(status) { + lastStatus = status; + + 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 || ''; + + dom.miniTrackTitle.textContent = status.title || fallbackTitle; + dom.miniArtist.textContent = status.artist || ''; + + const previousState = currentState; + currentState = status.state; + updatePlaybackState(status.state); + + const altText = status.title && status.artist + ? `${status.artist} – ${status.title}` + : status.title || t('player.no_media'); + dom.albumArt.alt = altText; + dom.miniAlbumArt.alt = altText; + + 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) { + 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; + 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; + } + } + + if (status.duration && status.position !== null) { + currentDuration = status.duration; + currentPosition = status.position; + lastPositionUpdate = Date.now(); + lastPositionValue = status.position; + updateProgress(status.position, status.duration); + } + + if (!isUserAdjustingVolume) { + dom.volumeSlider.value = status.volume; + dom.volumeDisplay.textContent = `${status.volume}%`; + dom.miniVolumeSlider.value = status.volume; + dom.miniVolumeDisplay.textContent = `${status.volume}%`; + } + + updateMuteIcon(status.muted); + + const src = resolveMediaSource(status.source); + dom.source.textContent = src ? src.name : t('player.unknown_source'); + dom.sourceIcon.innerHTML = src?.icon || ''; + + const hasMedia = status.state !== 'idle'; + dom.btnPlayPause.disabled = !hasMedia; + dom.btnNext.disabled = !hasMedia; + dom.btnPrevious.disabled = !hasMedia; + dom.miniBtnPlayPause.disabled = !hasMedia; + + 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() { + if (interpolationInterval) { + clearInterval(interpolationInterval); + } + 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; +} diff --git a/media_server/static/js/scripts.js b/media_server/static/js/scripts.js new file mode 100644 index 0000000..5d96ba7 --- /dev/null +++ b/media_server/static/js/scripts.js @@ -0,0 +1,537 @@ +// ============================================================ +// Scripts: CRUD, quick access, execution dialog +// ============================================================ + +let scriptFormDirty = false; + +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'); + + const fragment = document.createDocumentFragment(); + const hasScripts = scripts.length > 0; + let hasLinks = false; + + 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); + }); + + 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; + 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); + } + + 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); + } + + grid.innerHTML = ''; + grid.appendChild(fragment); + resolveMdiIcons(grid); +} + +async function executeScript(scriptName, buttonElement) { + const token = localStorage.getItem('media_server_token'); + 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 { + buttonElement.classList.remove('executing'); + } +} + +// ============================================================ +// Script Management CRUD +// ============================================================ + +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 showAddScriptDialog() { + const dialog = document.getElementById('scriptDialog'); + const form = document.getElementById('scriptForm'); + const title = document.getElementById('dialogTitle'); + + 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'); + + 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 { + 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; + } + + document.getElementById('scriptOriginalName').value = scriptName; + document.getElementById('scriptIsEdit').value = 'true'; + document.getElementById('scriptName').value = scriptName; + document.getElementById('scriptName').disabled = true; + 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; + + 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'); + 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() { + if (scriptFormDirty) { + if (!await showConfirm(t('scripts.confirm.unsaved'))) { + return; + } + } + + const dialog = document.getElementById('scriptDialog'); + scriptFormDirty = false; + 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'); + } else { + showToast(result.detail || 'Failed to delete script', 'error'); + } + } catch (error) { + console.error('Error deleting script:', error); + showToast('Error deleting script', 'error'); + } +} + +// ============================================================ +// Execution Result Dialog (shared by scripts and callbacks) +// ============================================================ + +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'); + + title.textContent = `Execution Result: ${name}`; + + 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'} +
+ `; + + 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)'; + } + + 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'); + + 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'); + + 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 { + 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'); + } +} diff --git a/media_server/static/js/websocket.js b/media_server/static/js/websocket.js new file mode 100644 index 0000000..8f162a8 --- /dev/null +++ b/media_server/static/js/websocket.js @@ -0,0 +1,169 @@ +// ============================================================ +// WebSocket: Connection, reconnection, authentication +// ============================================================ + +let reconnectTimeout = null; +let pingInterval = null; +let wsReconnectAttempts = 0; + +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) { + 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(); + loadAudioDevices(); + if (visualizerEnabled && visualizerAvailable) { + ws.send(JSON.stringify({ type: 'enable_visualizer' })); + } + }; + + 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(); + loadScriptsTable(); + } else if (msg.type === 'links_changed') { + console.log('Links changed, reloading...'); + loadHeaderLinks(); + loadLinksTable(); + displayQuickAccess(); + } else if (msg.type === 'audio_data') { + frequencyData = msg.data; + } 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) { + localStorage.removeItem('media_server_token'); + showAuthForm(t('auth.invalid')); + } else if (event.code !== 1000) { + 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 { + showConnectionBanner(t('connection.lost'), true); + } + } + }; + + 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); + } +}