f2c82164e8
- VU needle swings -22..+22deg instead of -45..+45 for a more realistic VU look - Switch from RMS to peak frequency reading so the needle catches musical hits - Faster attack (0.7) and release (0.25) so it swings rather than pinning - Replace explicit grid lines with subtle repeating-conic-gradient ticks - Scope mini progress bar styles to .mini-player; taller (3px), clickable
956 lines
36 KiB
JavaScript
956 lines
36 KiB
JavaScript
// ============================================================
|
||
// 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;
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
|
||
// 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 || !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) {
|
||
const slider = document.getElementById('volume-slider');
|
||
const vol = slider ? Number(slider.value) || 0 : 0;
|
||
// Volume slider modulates how loud the audio could be;
|
||
// multiply against the captured level so muting drops the
|
||
// needle even though the source is still playing.
|
||
const audioLevel = readAudioLevel();
|
||
let target;
|
||
if (audioLevel != null) {
|
||
// Real audio: scale by output volume & apply attack/release
|
||
// smoothing for analog-feeling ballistics.
|
||
const wanted = audioLevel * (vol / 100);
|
||
const k = wanted > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + wanted * k;
|
||
target = -22 + vuLevelSmoothed * 44;
|
||
} else {
|
||
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;
|
||
}
|
||
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);
|
||
}
|