Files
media-player-server/media_server/static/js/player.js
T
alexei.dolgolyov 336d596b66 fix(ui): full-width spectrum + log-mapped bars; deeper sepia + soft art fade
Spectrum
- Logarithmic frequency-to-bar mapping (squared time so bins
  stretch toward the highs). Per-bar high-end gain ramps from
  1.0x at the lowest bar to 3.0x at the highest, so the right
  half of the spectrum no longer reads as dead air.
- Floor bumped from 6% to 12% so silent bars stay visible.
- Skip bin 0 (DC + sub-rumble) which was overwhelming the lows.
- Use peak (not average) within each band — punchier visual.
- Container height 56→64, gradient now copper-lo → copper →
  copper-hi for more visible top tips. min-width: 0 / box-sizing
  border-box ensures the row truly claims the full grid column.
- Backend FFT path is unchanged: WS audio_data → setFrequencyData
  → renderVisualizerFrame → updateEditorialSpectrum. No
  client-side analyzer added.

Album art (vinyl label)
- Deeper sepia (0.35→0.6) and lower saturate (0.85→0.7) so
  vibrant covers blend into the copper grooves.
- Soft radial mask: outer ~22% of the disc fades toward the
  vinyl black so the album art dissolves into the surface
  rather than terminating at a hard clip edge.
- Hover state pulls the fade inward and eases sepia back so
  the user can still see the real cover at near-natural color.
- Glow tint matches the new sepia depth.
2026-04-25 02:07:20 +03:00

895 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')"
title="${p.name}"></div>`
).join('');
const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}"
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`;
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');
}
});
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
function getVinylAngle() {
const art = document.getElementById('album-art');
if (!art) return 0;
const st = getComputedStyle(art);
const tr = st.transform;
if (!tr || tr === 'none') return 0;
const m = tr.match(/matrix\((.+)\)/);
if (!m) return 0;
const vals = m[1].split(',').map(Number);
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
return ((angle % 360) + 360) % 360;
}
function saveVinylAngle() {
if (!vinylMode) return;
localStorage.setItem('vinylAngle', getVinylAngle());
}
function restoreVinylAngle() {
const saved = localStorage.getItem('vinylAngle');
if (saved) {
const art = document.getElementById('album-art');
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
}
}
setInterval(saveVinylAngle, 2000);
window.addEventListener('beforeunload', saveVinylAngle);
export function toggleVinylMode() {
if (vinylMode) saveVinylAngle();
vinylMode = !vinylMode;
localStorage.setItem('vinylMode', vinylMode);
applyVinylMode();
}
export function applyVinylMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('vinylToggle');
if (!container) return;
if (vinylMode) {
container.classList.add('vinyl');
if (btn) btn.classList.add('active');
restoreVinylAngle();
updateVinylSpin();
} else {
saveVinylAngle();
container.classList.remove('vinyl', 'spinning', 'paused');
if (btn) btn.classList.remove('active');
}
}
function updateVinylSpin() {
const container = document.querySelector('.album-art-container');
if (!container || !vinylMode) return;
container.classList.remove('spinning', 'paused');
if (currentPlayState === 'playing') {
container.classList.add('spinning');
} else if (currentPlayState === 'paused') {
container.classList.add('paused');
}
}
// Audio Visualizer
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
export let visualizerAvailable = false;
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);
}
const art = document.getElementById('album-art');
if (art) {
art.style.transform = '';
art.style.removeProperty('--vinyl-scale');
}
const glow = document.getElementById('album-art-glow');
if (glow) glow.style.opacity = '';
frequencyData = null;
smoothedFrequencies = null;
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();
}
const bass = frequencyData.bass || 0;
const scale = 1 + bass * 0.04;
const art = document.getElementById('album-art');
if (art) {
if (vinylMode) {
art.style.setProperty('--vinyl-scale', scale);
} else {
art.style.transform = `scale(${scale})`;
}
}
const glow = document.getElementById('album-art-glow');
if (glow) {
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
}
// 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, ~3.0 at the highest.
const gain = 1 + (i / barCount) * 2.0;
// Floor at 12% so silent bars are still visually present.
const pct = Math.max(12, Math.min(100, peak * 110 * 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);
}
if (status.current_device) {
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].value === status.current_device) {
select.selectedIndex = i;
break;
}
}
}
// Enhance with icon grid
const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
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' }));
}
}
} 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;
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();
if (visualizerEnabled) applyVisualizerMode();
showToast(t('settings.audio.device_changed'), 'success');
} else {
showToast(t('settings.audio.device_change_failed'), 'error');
}
} catch (e) {
showToast(t('settings.audio.device_change_failed'), 'error');
}
}
// ============================================================
// UI State Updates
// ============================================================
let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
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 -45deg..+45deg rotation.
const needle = document.getElementById('vuNeedle');
if (needle) {
const deg = -45 + (status.volume / 100) * 90;
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 synthetic wobble ──────────────────────────────
// Real audio-level analysis is only available when the visualizer
// is enabled. For the common case (visualizer off), drive the needle
// with a low-frequency pseudo-random walk that's bounded by current
// volume, so it looks alive without being noisy.
let vuWobbleHandle = null;
let vuWobbleStart = 0;
function startVuWobble() {
if (vuWobbleHandle) return;
vuWobbleStart = performance.now();
const tick = () => {
const needle = document.getElementById('vuNeedle');
if (needle) {
const slider = document.getElementById('volume-slider');
const vol = slider ? Number(slider.value) || 0 : 0;
const base = -45 + (vol / 100) * 90;
// Wobble magnitude scales with volume, capped at ~12deg either way.
const mag = Math.max(2, Math.min(14, vol * 0.16));
const t = (performance.now() - vuWobbleStart) / 1000;
// Two combined sines + a tiny random component for organic motion.
const wobble =
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(${base + wobble}deg)`;
}
vuWobbleHandle = requestAnimationFrame(tick);
};
vuWobbleHandle = requestAnimationFrame(tick);
}
function stopVuWobble() {
if (vuWobbleHandle) {
cancelAnimationFrame(vuWobbleHandle);
vuWobbleHandle = null;
}
// Settle needle back to the static volume-mapped position.
const needle = document.getElementById('vuNeedle');
const slider = document.getElementById('volume-slider');
if (needle && slider) {
const vol = Number(slider.value) || 0;
const base = -45 + (vol / 100) * 90;
needle.style.transform = `rotate(${base}deg)`;
}
}
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;
}
updateVinylSpin();
}
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
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);
}