// ============================================================ // 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'; } const metaThemeColor = document.querySelector('meta[name="theme-color"]'); if (metaThemeColor) { metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212'); } } 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.15; 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.04; 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.4 + bass * 0.4).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; }