Files
media-player-server/media_server/static/js/player.js
T
alexei.dolgolyov 59840a1190 feat(player): fullscreen "Listening Room" mode
Toggleable theater-scale player view that takes over the viewport
and amplifies the existing Studio Reference aesthetic — same fonts,
same copper/ink palette, just dialed up for immersive listening.

Layout & typography:
- Two-column centerfold: massive vinyl stage left (clamp(., 72vh, 720px)),
  editorial column right with Fraunces italic title at clamp(48px, 6.4vw,
  112px), Geist Mono console-style metadata strip, oversized timecodes,
  full-width amplitude spectrum.
- Mobile / portrait flips to vertical theater (vinyl top, masthead+
  transport below) at <=900px or any portrait orientation.

Ambient bloom:
- Duplicate of #album-art rendered behind everything at blur(110px)
  saturate(1.6) opacity(0.42) — paints the room in the record's color.
  Slow 28s drift animation. Light-theme variant at lower opacity.
- MutationObserver keeps bloom art in sync as tracks change.
- Vignette + edge darkening + subtle paper-grain veil frame the stage.

Interaction:
- Header button (corner-arrows-out icon) toggles; pressing 'F' anywhere
  outside text inputs also toggles; ESC exits.
- Native Fullscreen API requested as best-effort sugar on top of the
  CSS overlay (works on TV / tablet); CSS overlay alone covers the
  CSS-only fallback case (iOS Safari, embedded webviews).
- fullscreenchange listener mirrors OS-level exit back into the overlay.
- Auto-hide chrome + cursor after 2.5s idle, restored on mousemove.
- Focus moves to play/pause on enter; restored to invoking element on
  exit.
- Hides mini-player, tab bar, header, folio marks, and other tabs while
  active.

Motion:
- 320ms fade-in for the stage, 600ms vinyl rise, 1.4s bloom-in,
  staggered 80ms ladder for kicker -> title -> byline -> album -> meta
  -> spectrum -> transport. prefers-reduced-motion disables all.

i18n:
- player.fullscreen / player.fullscreen.exit / player.fullscreen.exit_short
  added to en.json and ru.json.

Files: index.html (header button + fs-chrome strip + fs-bloom layer),
styles.css (~360-line fullscreen block at end, scoped to body.is-
fullscreen-player), player.js (toggle + init + idle/key/observer
plumbing, ~170 lines), app.js (import + window export + init call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:47:53 +03:00

1053 lines
40 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');
}
});
// 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 = '<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' }));
}
}
// 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);
}