// ============================================================
// 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);
}