// ============================================================ // 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, getAuthHeaders, hasCredentials, } from './core.js'; import { updateBackgroundColors } from './background.js'; import { loadDisplayMonitors } from './links.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(); } 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)}`; } 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) { 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; updateBackgroundColors(); } 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; } 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 visualizerCtx = null; let visualizerAnimFrame = null; export let frequencyData = null; export function setFrequencyData(value) { frequencyData = value; } let smoothedFrequencies = null; const VISUALIZER_SMOOTHING = 0.15; 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() { 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(); } export 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); } frequencyData = null; smoothedFrequencies = null; document.body.classList.remove('audio-spectrum-live'); // Reset spectrum bar heights so the synthetic CSS animation takes back over document.querySelectorAll('.now-playing .spectrum > span').forEach(s => { s.style.height = ''; }); } 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(); } // Bass-driven album-art scale + glow pulse removed — the // "burst" looked unnatural on the sleeve. Spectrum bars + // VU needle remain the audio-reactive elements. // 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. function updateEditorialSpectrum(bins, numBins) { const root = document.querySelector('.now-playing .spectrum'); if (!root) return; const bars = root.children; const barCount = bars.length; if (!barCount) return; document.body.classList.add('audio-spectrum-live'); // Skip the very lowest bin (DC + sub-rumble) which often dominates. const lowBin = 1; const highBin = numBins - 1; for (let i = 0; i < barCount; i++) { // Logarithmic mapping: equal-area slices of the audible spectrum // map to equal numbers of bars. Each bar covers a wider bin range // toward the highs so they get amplified naturally. const t0 = i / barCount; const t1 = (i + 1) / barCount; const startIdx = Math.max(lowBin, Math.floor(lowBin + Math.pow(t0, 2.0) * (highBin - lowBin))); const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + Math.pow(t1, 2.0) * (highBin - lowBin))); let peak = 0; for (let j = startIdx; j < endIdx && j < numBins; j++) { if (bins[j] > peak) peak = bins[j]; } // Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest. // Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5) // so the master multiplier stays modest to avoid perma-clipping. const gain = 1 + (i / barCount) * 0.8; // Floor at 12% so silent bars are still visually present. const pct = Math.max(12, Math.min(100, peak * 65 * gain)); bars[i].style.height = pct + '%'; } } // 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 lastPositionUpdate = 0; let lastPositionValue = 0; let interpolationInterval = null; export 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); } }); } export function updateUI(status) { setLastStatus(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; 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%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(`/api/media/artwork?_=${Date.now()}`, { headers: getAuthHeaders() }) .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) { setCurrentDuration(status.duration); setCurrentPosition(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}%`; // 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. let vuWobbleHandle = null; let vuWobbleStart = 0; let vuLevelSmoothed = 0; 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). // The bins are renormalized per frame so peak-of-bins is useless for level. if (typeof frequencyData.level === 'number') return frequencyData.level; 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 * 1.4); } function startVuWobble() { if (vuWobbleHandle) return; vuWobbleStart = performance.now(); const tick = () => { const needle = document.getElementById('vuNeedle'); if (needle) { // Loopback capture is post-volume on Windows/macOS, so the // measured level already reflects the output knob — no extra // (vol/100) attenuation needed. const audioLevel = readAudioLevel(); let target; if (audioLevel != null) { // Real audio: apply attack/release smoothing for // analog-feeling ballistics. const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE; vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k; target = -22 + vuLevelSmoothed * 44; } else { const slider = document.getElementById('volume-slider'); const vol = slider ? Number(slider.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; } needle.style.transform = `rotate(${target}deg)`; } vuWobbleHandle = requestAnimationFrame(tick); }; vuWobbleHandle = requestAnimationFrame(tick); } function stopVuWobble() { if (vuWobbleHandle) { cancelAnimationFrame(vuWobbleHandle); vuWobbleHandle = null; } vuLevelSmoothed = 0; const needle = document.getElementById('vuNeedle'); if (needle) needle.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; } } 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; if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr; if (dom.metaLength) dom.metaLength.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); } 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; let fsBloomSyncObserver = null; function syncFullscreenBloomArt() { const src = document.getElementById('album-art'); const bloom = document.getElementById('fs-bloom-art'); if (!src || !bloom) return; if (src.src && src.src !== bloom.src) bloom.src = src.src; } 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); syncFullscreenBloomArt(); // Watch for album-art swaps so the bloom keeps up. const src = document.getElementById('album-art'); if (src && 'MutationObserver' in window) { if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect(); fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt); fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] }); } 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; } if (fsBloomSyncObserver) { fsBloomSyncObserver.disconnect(); fsBloomSyncObserver = 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); }