// ============================================================ // Player: Tabs, theme, accent, vinyl, visualizer, UI updates // ============================================================ import { dom, t, formatTime, showToast, resolveMediaSource, SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED, ws, currentState, setCurrentState, currentDuration, setCurrentDuration, currentPosition, setCurrentPosition, isUserAdjustingVolume, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume, getAuthHeaders, hasCredentials, } from './core.js'; import { updateBackgroundColors } from './background.js'; import { loadDisplayMonitors } from './links.js'; import { loadForegroundProcess } from './foreground.js'; import { IconSelect } from './icon-select.js'; // Tab management export let activeTab = 'player'; export function setMiniPlayerVisible(visible) { // On any non-player tab the mini player must stay visible regardless of scroll. if (activeTab !== 'player') visible = true; 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'); } } export 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 = ''; } } export 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(); loadForegroundProcess(); } 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 export function initTheme() { const savedTheme = localStorage.getItem('theme') || 'dark'; setTheme(savedTheme); } export 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'); } updateBackgroundColors(); } export function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme); } // Accent color management export 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' }, ]; export 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 darkenColor(hex, percent) { const num = parseInt(hex.replace('#', ''), 16); const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100)); const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100)); const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100)); return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } function hexToRgbTriple(hex) { const num = parseInt(hex.replace('#', ''), 16); const r = (num >> 16) & 0xff; const g = (num >> 8) & 0xff; const b = num & 0xff; return `${r}, ${g}, ${b}`; } export 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(); } export function applyAccentColor(color, hover) { const root = document.documentElement.style; root.setProperty('--accent', color); root.setProperty('--accent-hover', hover); // Editorial palette tokens — the redesign reads these directly, // so the picker must drive them too (the --accent alias alone has // no effect once components moved off it). root.setProperty('--copper', color); root.setProperty('--copper-hi', hover); root.setProperty('--copper-lo', darkenColor(color, 12)); root.setProperty('--copper-rgb', hexToRgbTriple(color)); // --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula // declared in styles.css, so it picks up the new RGB automatically. localStorage.setItem('accentColor', color); const dot = document.getElementById('accentDot'); if (dot) dot.style.background = color; updateBackgroundColors(); // Refresh the cached accent in the visualizer so the gradient // rebuilds on its next frame instead of querying CSS every frame. refreshVisualizerAccent(); } export 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; // Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes). dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => { el.addEventListener('click', () => { selectAccentColor(el.dataset.accentColor, el.dataset.accentHover); }); }); const customRowEl = dropdown.querySelector('[data-accent-custom-row]'); const customInput = dropdown.querySelector('#accentCustomInput'); if (customRowEl && customInput) { customRowEl.addEventListener('click', (e) => { // The native color popup only opens from a user-initiated click on // the . Forward clicks on the row to the input — except when // the input itself was the source (avoids re-entry). if (e.target !== customInput) customInput.click(); }); customInput.addEventListener('click', (e) => e.stopPropagation()); customInput.addEventListener('change', () => { selectAccentColor(customInput.value, lightenColor(customInput.value, 15)); }); } } export function selectAccentColor(color, hover) { applyAccentColor(color, hover); renderAccentSwatches(); document.getElementById('accentDropdown').classList.remove('open'); } export function toggleAccentPicker() { document.getElementById('accentDropdown').classList.toggle('open'); } document.addEventListener('click', (e) => { if (!e.target.closest('.accent-picker')) { document.getElementById('accentDropdown')?.classList.remove('open'); } }); // Audio Visualizer export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; export let visualizerAvailable = false; export function setVisualizerEnabled(value) { visualizerEnabled = !!value; localStorage.setItem('visualizerEnabled', visualizerEnabled); } let visualizerCanvas = null; // Cached canvas DOM ref let visualizerCtx = null; let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize) let visualizerAnimFrame = null; export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled) let frequencyDataVersion = 0; // Bumped on every setFrequencyData let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats) export function setFrequencyData(value) { frequencyData = value; frequencyDataVersion++; // Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale). if (value && typeof value.scale === 'number' && value.scale > 0) { frequenciesScale = 1.0 / value.scale; } else { frequenciesScale = 1.0; } } let smoothedFrequencies = null; const VISUALIZER_SMOOTHING = 0.15; // Cached accent — refreshed by applyAccentColor() rather than on every frame. let cachedAccentHex = '#1db954'; let cachedAccentRGB = '29,185,84'; function parseAccentHex(hex) { const h = (hex || '').trim().replace('#', ''); if (h.length < 6) return null; const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; return `${r},${g},${b}`; } export function refreshVisualizerAccent() { const accentHex = getComputedStyle(document.documentElement) .getPropertyValue('--accent').trim(); if (accentHex) { cachedAccentHex = accentHex; const rgb = parseAccentHex(accentHex); if (rgb) cachedAccentRGB = rgb; } // Force gradient rebuild on next frame. visualizerGradient = null; } export async function checkVisualizerAvailability() { try { const resp = await fetch('/api/media/visualizer/status', { headers: getAuthHeaders() }); 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'; } export function toggleVisualizer() { visualizerEnabled = !visualizerEnabled; localStorage.setItem('visualizerEnabled', visualizerEnabled); applyVisualizerMode(); } export 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() { visualizerCanvas = document.getElementById('spectrogram-canvas'); if (!visualizerCanvas) return; visualizerCtx = visualizerCanvas.getContext('2d'); visualizerCanvas.width = 300; visualizerCanvas.height = 64; visualizerGradient = null; // Force rebuild refreshVisualizerAccent(); } function buildVisualizerGradient() { if (!visualizerCtx || !visualizerCanvas) return null; const h = visualizerCanvas.height; const grad = visualizerCtx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`); grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`); return grad; } function startVisualizerRender() { if (visualizerAnimFrame) return; // Cache editorial spectrum bar refs once per start. cacheEditorialSpectrumBars(); renderVisualizerFrame(); } export function stopVisualizerRender() { if (visualizerAnimFrame) { cancelAnimationFrame(visualizerAnimFrame); visualizerAnimFrame = null; } if (visualizerCtx && visualizerCanvas) { visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.height); } frequencyData = null; frequencyDataVersion++; // Force next render to redraw cleared state lastRenderedVersion = -1; smoothedFrequencies = null; document.body.classList.remove('audio-spectrum-live'); // Reset spectrum bar transforms so the synthetic CSS animation takes back over. if (editorialSpectrumBars) { for (let i = 0; i < editorialSpectrumBars.length; i++) { editorialSpectrumBars[i].style.transform = ''; } } // Drop cached bars so next start re-queries. editorialSpectrumBars = null; editorialSpectrumLastScale = null; } function renderVisualizerFrame() { visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame); // VU needle + position progress always tick — they read live state // not bound to spectrum payloads. Keeping them in this single rAF // is cheaper than running a second rAF loop just for the needle. tickVuNeedle(); if (!frequencyData || !visualizerCtx || !visualizerCanvas) return; // FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes // at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so // bail when no new payload has arrived since the last draw. if (frequencyDataVersion === lastRenderedVersion) return; lastRenderedVersion = frequencyDataVersion; const bins = frequencyData.frequencies; const numBins = bins.length; const w = visualizerCanvas.width; const h = visualizerCanvas.height; const gap = 2; const barWidth = (w / numBins) - gap; const scale = frequenciesScale; if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) { smoothedFrequencies = new Float32Array(numBins); } for (let i = 0; i < numBins; i++) { const v = bins[i] * scale; smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING + v * (1 - VISUALIZER_SMOOTHING); } if (!visualizerGradient) visualizerGradient = buildVisualizerGradient(); visualizerCtx.clearRect(0, 0, w, h); visualizerCtx.fillStyle = visualizerGradient; visualizerCtx.beginPath(); 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; visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5); } visualizerCtx.fill(); // Drive the editorial .spectrum bars from the same frequency data. updateEditorialSpectrum(smoothedFrequencies, numBins); } // ─── Editorial spectrum (.spectrum bars) driven by audio ────── // The bin distribution from the FFT is heavy on lows (the bass + mids // dominate); a linear mapping leaves the right half of the spectrum // looking dead. Use a logarithmic frequency-to-bar mapping plus a // per-bar high-end gain so all bars carry visible motion. let editorialSpectrumBars = null; // Live HTMLCollection cached at start let editorialSpectrumBarCount = 0; let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded) let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar let editorialBarGains = null; // Pre-computed per-bar gain let editorialBarRangesForBins = -1; // numBins last used to compute ranges function cacheEditorialSpectrumBars() { const root = document.querySelector('.now-playing .spectrum'); if (!root) { editorialSpectrumBars = null; editorialSpectrumBarCount = 0; return; } editorialSpectrumBars = root.children; editorialSpectrumBarCount = editorialSpectrumBars.length; editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount); editorialSpectrumLastScale.fill(-1); // Pre-compute per-bar gain (constant for the lifetime of the bar list). editorialBarGains = new Float32Array(editorialSpectrumBarCount); for (let i = 0; i < editorialSpectrumBarCount; i++) { editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8; } editorialBarRangesForBins = -1; // Force range recompute on next call } function recomputeEditorialBarRanges(numBins) { const barCount = editorialSpectrumBarCount; editorialBarRanges = new Int16Array(barCount * 2); const lowBin = 1; const highBin = numBins - 1; const span = highBin - lowBin; for (let i = 0; i < barCount; i++) { const t0 = i / barCount; const t1 = (i + 1) / barCount; const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span)); const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span)); editorialBarRanges[i * 2] = startIdx; editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins); } editorialBarRangesForBins = numBins; } function updateEditorialSpectrum(bins, numBins) { if (!editorialSpectrumBars) cacheEditorialSpectrumBars(); const barCount = editorialSpectrumBarCount; if (!barCount) return; if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins); document.body.classList.add('audio-spectrum-live'); const ranges = editorialBarRanges; const gains = editorialBarGains; const lastScale = editorialSpectrumLastScale; const bars = editorialSpectrumBars; for (let i = 0; i < barCount; i++) { const startIdx = ranges[i * 2]; const endIdx = ranges[i * 2 + 1]; let peak = 0; for (let j = startIdx; j < endIdx; j++) { const v = bins[j]; if (v > peak) peak = v; } // Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5). // Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible. const raw = peak * 0.65 * gains[i]; const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw); // Quantize to 1/1000 — anything finer is invisible. Skip the DOM // write when the bar hasn't moved. const q = (scaleY * 1000) | 0; if (q === lastScale[i]) continue; lastScale[i] = q; // transform: scaleY runs on the compositor — no layout/paint. bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`; } } // Audio device selection let _audioDeviceIconSelect = null; export async function loadAudioDevices() { const section = document.getElementById('audioDeviceSection'); const select = document.getElementById('audioDeviceSelect'); if (!section || !select) return; try { const [devicesResp, statusResp] = await Promise.all([ fetch('/api/media/visualizer/devices', { headers: getAuthHeaders() }), fetch('/api/media/visualizer/status', { headers: getAuthHeaders() }) ]); 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); } // Prefer server-reported device; fall back to the last user choice // saved in localStorage (so reloads persist even if the server // forgets between restarts). const savedDevice = localStorage.getItem('audioDevice') || ''; const targetDevice = status.current_device || savedDevice; let pendingPushToServer = false; if (targetDevice) { for (let i = 0; i < select.options.length; i++) { if (select.options[i].value === targetDevice) { select.selectedIndex = i; break; } } // If the saved device wasn't on the server, push it back so // capture starts on the right one. if (!status.current_device && savedDevice) { pendingPushToServer = true; } } // Enhance with icon grid const audioSvg = ''; const items = [ { value: '', icon: audioSvg, label: t('settings.audio.auto') }, ...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })), ]; if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy(); _audioDeviceIconSelect = new IconSelect({ target: select, items, columns: 1, horizontal: true, onChange: () => onAudioDeviceChanged(), }); _audioDeviceIconSelect.setValue(select.value, false); // Sync visualizerAvailable from the fetched status so that // applyVisualizerMode() and the toggle button are consistent. visualizerAvailable = status.available; const btn = document.getElementById('visualizerToggle'); if (btn) btn.style.display = visualizerAvailable ? '' : 'none'; updateAudioDeviceStatus(status); // Re-subscribe the WebSocket if the user had the visualizer enabled. if (visualizerEnabled && visualizerAvailable) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'enable_visualizer' })); } } // If the user's previously-chosen device wasn't recognized by // the server (e.g. server restart cleared in-memory state), // push it back so capture lands on the right one. if (pendingPushToServer && savedDevice) { try { await fetch('/api/media/visualizer/device', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ device_name: savedDevice }) }); } catch (_) { /* best-effort */ } } } 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'); } } export async function onAudioDeviceChanged() { const select = document.getElementById('audioDeviceSelect'); if (!select) return; const deviceName = select.value || null; // Persist locally so reloads survive even if the server doesn't. if (deviceName) { localStorage.setItem('audioDevice', deviceName); } else { localStorage.removeItem('audioDevice'); } try { const resp = await fetch('/api/media/visualizer/device', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ device_name: deviceName }) }); if (resp.ok) { const result = await resp.json(); updateAudioDeviceStatus({ available: result.success, ...result }); await checkVisualizerAvailability(); // Picking a device is an explicit signal the user wants // capture: auto-enable the visualizer if it isn't already on. if (!visualizerEnabled && visualizerAvailable) { setVisualizerEnabled(true); } 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 artworkFetchGen = 0; let artworkAbort = null; let lastPositionUpdate = 0; let lastPositionValue = 0; let interpolationInterval = null; export function setupProgressDrag(bar, fill) { // Listeners are attached on mousedown and removed on mouseup so the // document doesn't carry per-progress-bar move handlers for the entire // session (especially expensive on mobile). 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 pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) { if (currentDuration <= 0) return; bar.classList.add('dragging'); updatePreview(getPercent(getX)); function onMove(e) { updatePreview(getPercent(getMoveX(e))); } function onEnd(e) { document.removeEventListener(moveEvent, onMove); document.removeEventListener(endEvent, onEnd); bar.classList.remove('dragging'); const clientX = getEndX(e); if (clientX !== undefined) seek(getPercent(clientX) * currentDuration); } document.addEventListener(moveEvent, onMove); document.addEventListener(endEvent, onEnd); } bar.addEventListener('mousedown', (e) => { e.preventDefault(); pointerStart(e.clientX, 'mousemove', 'mouseup', (ev) => ev.clientX, (ev) => ev.clientX); }); bar.addEventListener('touchstart', (e) => { pointerStart(e.touches[0].clientX, 'touchmove', 'touchend', (ev) => ev.touches[0].clientX, (ev) => ev.changedTouches?.[0]?.clientX); }, { passive: true }); bar.addEventListener('click', (e) => { if (currentDuration > 0) { seek(getPercent(e.clientX) * currentDuration); } }); } // Replace the album-art src and replay the .is-swapping CSS animation // so the new artwork crossfades in instead of popping. Re-toggling the // class across rAF restarts the keyframes even if it was already on. // // `forceAnim=false` skips the keyframe-restart reflow when the element // has never run the swap animation before — saves a synchronous layout // flush on first paint. The reflow IS still required when the class // is currently applied; otherwise the browser coalesces add+remove and // the keyframes don't replay. function swapArtworkSrc(imgEl, newSrc) { if (!imgEl) return; if (imgEl.src === newSrc) return; const wasSwapping = imgEl.classList.contains('is-swapping'); if (wasSwapping) { imgEl.classList.remove('is-swapping'); // Forced reflow restarts the keyframes — only needed when we have // to interrupt an in-flight animation. void imgEl.offsetWidth; } imgEl.src = newSrc; imgEl.classList.add('is-swapping'); } // Hash of the last fully-rendered status payload — lets us skip // updateUI altogether when the backend re-broadcasts the same state. let lastStatusFingerprint = null; function statusFingerprint(s) { return [ s.state, s.title, s.artist, s.album, s.volume, s.muted, s.duration, s.source, s.album_art_url, s.position ].join('|'); } export function updateUI(status) { setLastStatus(status); // Idempotence: if nothing meaningful changed, skip the entire DOM // pass. Track switches arrive as 1-3 status_update broadcasts in // quick succession; this gates the redundant ones. const fingerprint = statusFingerprint(status); if (fingerprint === lastStatusFingerprint) return; lastStatusFingerprint = fingerprint; 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; setCurrentState(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%3Cpath fill='%236a6a6a' opacity='0.35' 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"; // Cancel any in-flight artwork fetch and bump the generation so a // late response from a previous track cannot overwrite the new one. if (artworkAbort) { try { artworkAbort.abort(); } catch { /* ignore */ } } const myGen = ++artworkFetchGen; artworkAbort = new AbortController(); if (artworkSource) { fetch('/api/media/artwork', { headers: getAuthHeaders(), signal: artworkAbort.signal, }) .then(r => r.ok ? r.blob() : null) .then(blob => { if (!blob || myGen !== artworkFetchGen) return; const oldBlobUrl = currentArtworkBlobUrl; const url = URL.createObjectURL(blob); currentArtworkBlobUrl = url; swapArtworkSrc(dom.albumArt, url); if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url; if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url; syncFullscreenBloomArt(url); if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); }) .catch(err => { if (err && err.name === 'AbortError') return; console.error('Artwork fetch failed:', err); }); } else { if (currentArtworkBlobUrl) { URL.revokeObjectURL(currentArtworkBlobUrl); currentArtworkBlobUrl = null; } swapArtworkSrc(dom.albumArt, placeholderArt); if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt; if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow; syncFullscreenBloomArt(placeholderGlow); } } if (status.duration && status.position !== null) { // Only redo the progress DOM when position actually changed. const positionChanged = status.duration !== currentDuration || Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05; setCurrentDuration(status.duration); setCurrentPosition(status.position); lastPositionUpdate = Date.now(); lastPositionValue = status.position; if (positionChanged) updateProgress(status.position, status.duration); } if (!isUserAdjustingVolume) { // Re-seed the throttling cache so a future call to setVolume() with // the previously-sent value still propagates after an external change. notifyRemoteVolume(status.volume); dom.volumeSlider.value = status.volume; dom.volumeDisplay.textContent = `${status.volume}%`; dom.miniVolumeSlider.value = status.volume; dom.miniVolumeDisplay.textContent = `${status.volume}%`; // VU needle: map 0-100 volume to -22deg..+22deg rotation. const needle = document.getElementById('vuNeedle'); if (needle) { const deg = -22 + (status.volume / 100) * 44; needle.style.transform = `rotate(${deg}deg)`; } // Editorial VU readout: VOL XX% / OUT (SYS or MUTED) const vuVol = document.getElementById('vu-vol'); if (vuVol) vuVol.textContent = `${status.volume}%`; const vuOut = document.getElementById('vu-out'); if (vuOut) vuOut.textContent = status.muted ? 'MUTE' : 'SYS'; } 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(); } } // ─── VU needle ─────────────────────────────────────────────── // The needle reflects ACTUAL audio output level (computed from the // FFT data the visualizer feeds in). When audio capture isn't // running, fall back to a synthetic wobble bounded by the volume // slider position so the needle still looks alive. // // One unified rAF drives both the spectrum and the VU needle (see // renderVisualizerFrame → tickVuNeedle). If the visualizer isn't // rendering, a separate rAF takes over solely for the needle. let vuStandaloneHandle = null; let vuWobbleStart = 0; let vuLevelSmoothed = 0; let vuNeedleEl = null; // Cached needle element let vuVolumeSliderEl = null; // Cached slider element let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins function readAudioLevel() { if (!frequencyData) return null; // Backend sends a true loudness signal (RMS-derived dB, 0..1) — // either as float (legacy) or scaled int (new format). if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale; if (!frequencyData.frequencies) return null; const bins = frequencyData.frequencies; if (!bins.length) return null; let peak = 0; for (let i = 1; i < bins.length; i++) { if (bins[i] > peak) peak = bins[i]; } return Math.min(1, peak * frequenciesScale * 1.4); } function tickVuNeedle() { if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle'); if (!vuNeedleEl) return; const audioLevel = readAudioLevel(); let target; if (audioLevel != null) { const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; target = -22 + vuLevelSmoothed * 44; } else { if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider'); const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0; const base = -22 + (vol / 100) * 44; const mag = Math.max(2, Math.min(14, vol * 0.16)); const t = (performance.now() - vuWobbleStart) / 1000; target = base + Math.sin(t * 6.3) * mag * 0.55 + Math.sin(t * 11.7 + 1.3) * mag * 0.30 + (Math.random() - 0.5) * mag * 0.30; } // Quantize to 0.1° — finer is invisible. Skip when unchanged. const q = Math.round(target * 10) / 10; if (q === vuLastAppliedDeg) return; vuLastAppliedDeg = q; vuNeedleEl.style.transform = `rotate(${q}deg)`; } function startVuWobble() { vuWobbleStart = performance.now(); // If the visualizer rAF is already running, it ticks the needle for us. if (visualizerAnimFrame) return; if (vuStandaloneHandle) return; const standalone = () => { tickVuNeedle(); // Stop ourselves once the unified visualizer loop is up. if (visualizerAnimFrame) { vuStandaloneHandle = null; return; } vuStandaloneHandle = requestAnimationFrame(standalone); }; vuStandaloneHandle = requestAnimationFrame(standalone); } function stopVuWobble() { if (vuStandaloneHandle) { cancelAnimationFrame(vuStandaloneHandle); vuStandaloneHandle = null; } vuLevelSmoothed = 0; vuLastAppliedDeg = -999; if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle'); if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)'; } export function updatePlaybackState(state) { setCurrentPlayState(state); // Expose state to CSS so tonearm / vinyl spin can react. document.documentElement.dataset.playstate = state; // Drive the VU needle wobble — running only while playing. if (state === 'playing') startVuWobble(); else stopVuWobble(); 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; } } // Cache last applied progress values so we can skip DOM writes when the // rounded second hasn't moved. Width is quantized to 0.1% — finer is // invisible but would still trigger compositor work. let lastProgressTenths = -1; // 0..1000 (0.1% increments) let lastProgressSec = -1; let lastDurationSec = -1; let cachedMiniBar = null; function updateProgress(position, duration) { const percent = (position / duration) * 100; const tenths = Math.round(percent * 10); // 0..1000 const posRound = Math.round(position); const durRound = Math.round(duration); const widthChanged = tenths !== lastProgressTenths; const posChanged = posRound !== lastProgressSec; const durChanged = durRound !== lastDurationSec; if (widthChanged) { lastProgressTenths = tenths; const widthStr = (tenths / 10) + '%'; dom.progressFill.style.width = widthStr; dom.miniProgressFill.style.width = widthStr; if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); } if (posChanged) { lastProgressSec = posRound; const currentStr = formatTime(position); dom.currentTime.textContent = currentStr; if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr; dom.miniCurrentTime.textContent = currentStr; dom.progressBar.setAttribute('aria-valuenow', posRound); } if (durChanged) { lastDurationSec = durRound; const totalStr = formatTime(duration); dom.totalTime.textContent = totalStr; if (dom.metaLength) dom.metaLength.textContent = totalStr; dom.miniTotalTime.textContent = totalStr; dom.progressBar.dataset.duration = duration; dom.progressBar.setAttribute('aria-valuemax', durRound); } if (posChanged || durChanged) { if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar'); if (cachedMiniBar) { if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound); if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound); } } } export 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); } export 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; const vuOut = document.getElementById('vu-out'); if (vuOut) vuOut.textContent = muted ? 'MUTE' : 'SYS'; const cluster = document.querySelector('.now-playing .vu-cluster'); if (cluster) cluster.classList.toggle('muted', muted); } // ============================================================ // Fullscreen player mode — Listening Room // // Two-layer model: // 1. CSS overlay (`body.is-fullscreen-player`) — works everywhere, // reuses existing player markup, takes over the viewport via // position:fixed. // 2. Native Fullscreen API on top — true OS-level fullscreen when // the user agent allows it. The CSS class is the source of truth; // the native API is best-effort sugar. // ============================================================ let fsChromeIdleTimer = null; const FS_CHROME_IDLE_MS = 2500; let fsLastFocusedElement = null; // Mirror the album-art onto #fs-bloom-art (the fullscreen ambient // bloom). Called directly from the artwork-swap path — no // MutationObserver, so we never repaint the 110px-radius blur twice. function syncFullscreenBloomArt(url) { const bloom = document.getElementById('fs-bloom-art'); if (!bloom) return; const target = url || (dom && dom.albumArt && dom.albumArt.src) || ''; if (target && bloom.src !== target) bloom.src = target; } function showFsChrome() { document.body.classList.remove('fs-chrome-hidden'); if (fsChromeIdleTimer) clearTimeout(fsChromeIdleTimer); if (document.body.classList.contains('is-fullscreen-player')) { fsChromeIdleTimer = setTimeout(() => { document.body.classList.add('fs-chrome-hidden'); }, FS_CHROME_IDLE_MS); } } function onFsMouseMove() { showFsChrome(); } function onFsKeyDown(e) { // ESC exits regardless of focus location (native API also dispatches its own, // but we handle the CSS-only fallback case here). if (e.key === 'Escape' && document.body.classList.contains('is-fullscreen-player')) { e.preventDefault(); exitPlayerFullscreen(); } } function onGlobalFsHotkey(e) { // 'F' toggles fullscreen — but never when user is typing into a field. if (e.key !== 'f' && e.key !== 'F') return; const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.target && e.target.isContentEditable) return; if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); togglePlayerFullscreen(); } function onNativeFullscreenChange() { // If the user pressed ESC at the OS level or otherwise exited native // fullscreen, mirror the state in our CSS overlay. const hasNative = !!document.fullscreenElement; const hasOverlay = document.body.classList.contains('is-fullscreen-player'); if (!hasNative && hasOverlay) { // User left native fullscreen — also drop the overlay so the UI // returns to its normal state in one motion. exitPlayerFullscreen({ skipNativeExit: true }); } } function updateFullscreenButtonIcons(active) { const enter = document.getElementById('fullscreen-icon-enter'); const exit = document.getElementById('fullscreen-icon-exit'); if (enter) enter.style.display = active ? 'none' : ''; if (exit) exit.style.display = active ? '' : 'none'; const btn = document.getElementById('fullscreenToggle'); if (btn) { btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); } } export function enterPlayerFullscreen() { if (document.body.classList.contains('is-fullscreen-player')) return; // If we're not on the player tab, jump to it first so the markup is visible. if (activeTab !== 'player') switchTab('player'); fsLastFocusedElement = document.activeElement; document.body.classList.add('is-fullscreen-player'); setMiniPlayerVisible(false); updateFullscreenButtonIcons(true); // Initial mirror — subsequent swaps are pushed by updateUI directly, // so there is no MutationObserver in the hot path. syncFullscreenBloomArt(); document.addEventListener('mousemove', onFsMouseMove, { passive: true }); document.addEventListener('keydown', onFsKeyDown); showFsChrome(); // Move keyboard focus onto the play/pause button so Space/Enter immediately // controls playback once the user enters the room. const playBtn = document.getElementById('btn-play-pause'); if (playBtn) playBtn.focus({ preventScroll: true }); // Best-effort native fullscreen. Failure is silent — the CSS overlay // already gives the user the immersive view. const target = document.documentElement; if (target.requestFullscreen && !document.fullscreenElement) { target.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}); } localStorage.setItem('fullscreenPlayerEnabled', 'true'); } export function exitPlayerFullscreen({ skipNativeExit = false } = {}) { if (!document.body.classList.contains('is-fullscreen-player')) return; document.body.classList.remove('is-fullscreen-player', 'fs-chrome-hidden'); updateFullscreenButtonIcons(false); if (fsChromeIdleTimer) { clearTimeout(fsChromeIdleTimer); fsChromeIdleTimer = null; } document.removeEventListener('mousemove', onFsMouseMove); document.removeEventListener('keydown', onFsKeyDown); if (!skipNativeExit && document.fullscreenElement && document.exitFullscreen) { document.exitFullscreen().catch(() => {}); } // Re-evaluate mini-player visibility against scroll position. if (activeTab === 'player') { const playerContainer = document.querySelector('.player-container'); if (playerContainer) { const rect = playerContainer.getBoundingClientRect(); const inView = rect.top < window.innerHeight && rect.bottom > 0; setMiniPlayerVisible(!inView); } } else { setMiniPlayerVisible(true); } // Restore focus to whatever invoked the toggle. if (fsLastFocusedElement && typeof fsLastFocusedElement.focus === 'function') { try { fsLastFocusedElement.focus({ preventScroll: true }); } catch (_) {} } fsLastFocusedElement = null; localStorage.removeItem('fullscreenPlayerEnabled'); } export function togglePlayerFullscreen() { if (document.body.classList.contains('is-fullscreen-player')) { exitPlayerFullscreen(); } else { enterPlayerFullscreen(); } } export function initPlayerFullscreen() { document.addEventListener('keydown', onGlobalFsHotkey); document.addEventListener('fullscreenchange', onNativeFullscreenChange); }