Split 3803-line app.js into focused modules: - core.js: shared state, utilities, i18n, API commands, MDI icons - player.js: tabs, theme, accent, vinyl, visualizer, UI updates - websocket.js: connection, auth, reconnection - scripts.js: scripts CRUD, quick access, execution dialog - callbacks.js: callbacks CRUD - browser.js: media file browser, thumbnails, pagination, search - links.js: links CRUD, header links, display controls - main.js: DOMContentLoaded init orchestrator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
735 lines
26 KiB
JavaScript
735 lines
26 KiB
JavaScript
// ============================================================
|
||
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
|
||
// ============================================================
|
||
|
||
// Tab management
|
||
let activeTab = 'player';
|
||
|
||
function setMiniPlayerVisible(visible) {
|
||
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');
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
}
|
||
}
|
||
|
||
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
|
||
function initTheme() {
|
||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||
setTheme(savedTheme);
|
||
}
|
||
|
||
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';
|
||
}
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||
setTheme(newTheme);
|
||
}
|
||
|
||
// Accent color management
|
||
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' },
|
||
];
|
||
|
||
function lightenColor(hex, percent) {
|
||
const num = parseInt(hex.replace('#', ''), 16);
|
||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
|
||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||
}
|
||
|
||
function 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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function selectAccentColor(color, hover) {
|
||
applyAccentColor(color, hover);
|
||
renderAccentSwatches();
|
||
document.getElementById('accentDropdown').classList.remove('open');
|
||
}
|
||
|
||
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);
|
||
|
||
function toggleVinylMode() {
|
||
if (vinylMode) saveVinylAngle();
|
||
vinylMode = !vinylMode;
|
||
localStorage.setItem('vinylMode', vinylMode);
|
||
applyVinylMode();
|
||
}
|
||
|
||
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
|
||
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||
let visualizerAvailable = false;
|
||
let visualizerCtx = null;
|
||
let visualizerAnimFrame = null;
|
||
let frequencyData = null;
|
||
let smoothedFrequencies = null;
|
||
const VISUALIZER_SMOOTHING = 0.65;
|
||
|
||
async function checkVisualizerAvailability() {
|
||
try {
|
||
const token = localStorage.getItem('media_server_token');
|
||
const resp = await fetch('/api/media/visualizer/status', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
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';
|
||
}
|
||
|
||
function toggleVisualizer() {
|
||
visualizerEnabled = !visualizerEnabled;
|
||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||
applyVisualizerMode();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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.03;
|
||
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.5 + bass * 0.3).toFixed(2);
|
||
}
|
||
}
|
||
|
||
// Audio device selection
|
||
async function loadAudioDevices() {
|
||
const section = document.getElementById('audioDeviceSection');
|
||
const select = document.getElementById('audioDeviceSelect');
|
||
if (!section || !select) return;
|
||
|
||
try {
|
||
const token = localStorage.getItem('media_server_token');
|
||
|
||
const [devicesResp, statusResp] = await Promise.all([
|
||
fetch('/api/media/visualizer/devices', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
}),
|
||
fetch('/api/media/visualizer/status', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
]);
|
||
|
||
if (!devicesResp.ok || !statusResp.ok) return;
|
||
|
||
const devices = await devicesResp.json();
|
||
const status = await statusResp.json();
|
||
|
||
if (!status.available && devices.length === 0) {
|
||
section.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
section.style.display = '';
|
||
|
||
while (select.options.length > 1) select.remove(1);
|
||
for (const dev of devices) {
|
||
const opt = document.createElement('option');
|
||
opt.value = dev.name;
|
||
opt.textContent = dev.name;
|
||
select.appendChild(opt);
|
||
}
|
||
|
||
if (status.current_device) {
|
||
for (let i = 0; i < select.options.length; i++) {
|
||
if (select.options[i].value === status.current_device) {
|
||
select.selectedIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
updateAudioDeviceStatus(status);
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
async function onAudioDeviceChanged() {
|
||
const select = document.getElementById('audioDeviceSelect');
|
||
if (!select) return;
|
||
|
||
const deviceName = select.value || null;
|
||
const token = localStorage.getItem('media_server_token');
|
||
|
||
try {
|
||
const resp = await fetch('/api/media/visualizer/device', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ device_name: deviceName })
|
||
});
|
||
|
||
if (resp.ok) {
|
||
const result = await resp.json();
|
||
updateAudioDeviceStatus(result);
|
||
await checkVisualizerAvailability();
|
||
if (visualizerEnabled) applyVisualizerMode();
|
||
showToast(t('settings.audio.device_changed'), 'success');
|
||
} else {
|
||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// UI State Updates
|
||
// ============================================================
|
||
|
||
let lastArtworkKey = null;
|
||
let currentArtworkBlobUrl = null;
|
||
let lastPositionUpdate = 0;
|
||
let lastPositionValue = 0;
|
||
let interpolationInterval = null;
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateUI(status) {
|
||
lastStatus = 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;
|
||
currentState = 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) {
|
||
const token = localStorage.getItem('media_server_token');
|
||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
.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) {
|
||
currentDuration = status.duration;
|
||
currentPosition = 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}%`;
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
function updatePlaybackState(state) {
|
||
currentPlayState = state;
|
||
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;
|
||
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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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;
|
||
}
|