a0f74dfc39
Spectrum width - grid-auto-flow: column with implicit columns wasn't reliably stretching to fill the parent. Switch to explicit grid-template-columns: repeat(var(--spectrum-bars), minmax(0, 1fr)) with the bar count exposed as a CSS variable from JS so the column count and the actual bar count stay in sync. - !important on display/grid-template-columns/width to defeat any legacy descendant rules. Device selection - Picking a device in the audio-device dropdown is an explicit signal that the user wants capture. Auto-enable the visualizer if it isn't already on, then call applyVisualizerMode so the WS subscription happens and the badge flips from 'Available' to 'Active'. Was only doing this when visualizer was already on, which is why the user kept seeing 'Available, not capturing'.
935 lines
35 KiB
JavaScript
935 lines
35 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 -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);
|
||
}
|