Backend optimizations, frontend optimizations, and UI design improvements
Backend optimizations: - GZip middleware for compressed responses - Concurrent WebSocket broadcast - Skip status polling when no clients connected - Deduplicated token validation with caching - Fire-and-forget HA state callbacks - Single stat() per browser item - Metadata caching (LRU) - M3U playlist optimization - Autostart setup (Task Scheduler + hidden VBS launcher) Frontend code optimizations: - Fix thumbnail blob URL memory leak - Fix WebSocket ping interval leak on reconnect - Skip artwork re-fetch when same track playing - Deduplicate volume slider logic - Extract magic numbers into named constants - Standardize error handling with toast notifications - Cache play/pause SVG constants - Loading state management for async buttons - Request deduplication for rapid clicks - Cache 30+ DOM element references - Deduplicate volume updates over WebSocket Frontend design improvements: - Progress bar seek thumb and hover expansion - Custom themed scrollbars - Toast notification accent border strips - Keyboard focus-visible states - Album art ambient glow effect - Animated sliding tab indicator - Mini-player top progress line - Empty state SVG illustrations - Responsive tablet breakpoint (601-900px) - Horizontal player layout on wide screens (>900px) - Glassmorphism mini-player with backdrop blur - Vinyl spin animation (toggleable) - Table horizontal scroll on narrow screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,64 @@
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
// Empty state illustration SVGs
|
||||
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
function emptyStateHtml(svgStr, text) {
|
||||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||||
}
|
||||
|
||||
// Cached DOM references (populated once after DOMContentLoaded)
|
||||
const dom = {};
|
||||
function cacheDom() {
|
||||
dom.trackTitle = document.getElementById('track-title');
|
||||
dom.artist = document.getElementById('artist');
|
||||
dom.album = document.getElementById('album');
|
||||
dom.miniTrackTitle = document.getElementById('mini-track-title');
|
||||
dom.miniArtist = document.getElementById('mini-artist');
|
||||
dom.albumArt = document.getElementById('album-art');
|
||||
dom.albumArtGlow = document.getElementById('album-art-glow');
|
||||
dom.miniAlbumArt = document.getElementById('mini-album-art');
|
||||
dom.volumeSlider = document.getElementById('volume-slider');
|
||||
dom.volumeDisplay = document.getElementById('volume-display');
|
||||
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
|
||||
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.progressBar = document.getElementById('progress-bar');
|
||||
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
||||
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
||||
dom.miniTotalTime = document.getElementById('mini-total-time');
|
||||
dom.playbackState = document.getElementById('playback-state');
|
||||
dom.stateIcon = document.getElementById('state-icon');
|
||||
dom.playPauseIcon = document.getElementById('play-pause-icon');
|
||||
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
|
||||
dom.muteIcon = document.getElementById('mute-icon');
|
||||
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
|
||||
dom.statusDot = document.getElementById('status-dot');
|
||||
dom.source = document.getElementById('source');
|
||||
dom.btnPlayPause = document.getElementById('btn-play-pause');
|
||||
dom.btnNext = document.getElementById('btn-next');
|
||||
dom.btnPrevious = document.getElementById('btn-previous');
|
||||
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
|
||||
dom.miniPlayer = document.getElementById('mini-player');
|
||||
}
|
||||
|
||||
// Timing constants
|
||||
const VOLUME_THROTTLE_MS = 16;
|
||||
const POSITION_INTERPOLATION_MS = 100;
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
const WS_RECONNECT_MS = 3000;
|
||||
const WS_PING_INTERVAL_MS = 30000;
|
||||
const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
// Tab management
|
||||
let activeTab = 'player';
|
||||
|
||||
@@ -12,6 +73,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Force reflow, then re-enable transition
|
||||
indicator.offsetHeight;
|
||||
indicator.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
activeTab = tabName;
|
||||
|
||||
@@ -30,7 +108,10 @@
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
updateTabIndicator(activeBtn);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
@@ -75,6 +156,41 @@
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
// Vinyl mode
|
||||
let vinylMode = localStorage.getItem('vinylMode') === 'true';
|
||||
let currentPlayState = 'idle';
|
||||
|
||||
function toggleVinylMode() {
|
||||
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');
|
||||
updateVinylSpin();
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Locale management
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
@@ -240,6 +356,7 @@
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
@@ -247,6 +364,7 @@
|
||||
let volumeUpdateTimer = null; // Timer for throttling volume updates
|
||||
let scripts = [];
|
||||
let lastStatus = null; // Store last status for locale switching
|
||||
let lastArtworkSource = null; // Track artwork source to skip redundant loads
|
||||
|
||||
// Dialog dirty state tracking
|
||||
let scriptFormDirty = false;
|
||||
@@ -259,9 +377,15 @@
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
|
||||
// Initialize theme
|
||||
initTheme();
|
||||
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
|
||||
// Initialize locale (async - loads JSON file)
|
||||
await initLocale();
|
||||
|
||||
@@ -278,60 +402,61 @@
|
||||
showAuthForm();
|
||||
}
|
||||
|
||||
// Volume slider event
|
||||
const volumeSlider = document.getElementById('volume-slider');
|
||||
volumeSlider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
const volume = parseInt(e.target.value);
|
||||
document.getElementById('volume-display').textContent = `${volume}%`;
|
||||
// Shared volume slider setup (avoids duplicate handler code)
|
||||
function setupVolumeSlider(sliderId) {
|
||||
const slider = document.getElementById(sliderId);
|
||||
slider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
const volume = parseInt(e.target.value);
|
||||
// Sync both sliders and displays
|
||||
dom.volumeDisplay.textContent = `${volume}%`;
|
||||
dom.miniVolumeDisplay.textContent = `${volume}%`;
|
||||
dom.volumeSlider.value = volume;
|
||||
dom.miniVolumeSlider.value = volume;
|
||||
|
||||
// Throttle volume updates while dragging (update every 16ms via WebSocket)
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
}
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, VOLUME_THROTTLE_MS);
|
||||
});
|
||||
|
||||
slider.addEventListener('change', (e) => {
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
}
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, 16);
|
||||
});
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
volumeSlider.addEventListener('change', (e) => {
|
||||
// Clear any pending throttled update
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
}
|
||||
|
||||
// Send final volume update immediately
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
|
||||
});
|
||||
setupVolumeSlider('volume-slider');
|
||||
setupVolumeSlider('mini-volume-slider');
|
||||
|
||||
// Restore saved tab
|
||||
const savedTab = localStorage.getItem('activeTab') || 'player';
|
||||
switchTab(savedTab);
|
||||
// Snap indicator to initial position without animation
|
||||
const initialActiveBtn = document.querySelector('.tab-btn.active');
|
||||
if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false);
|
||||
|
||||
// Re-position tab indicator on window resize
|
||||
window.addEventListener('resize', () => {
|
||||
const activeBtn = document.querySelector('.tab-btn.active');
|
||||
if (activeBtn) updateTabIndicator(activeBtn, false);
|
||||
});
|
||||
|
||||
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view
|
||||
const playerContainer = document.querySelector('.player-container');
|
||||
const miniPlayer = document.getElementById('mini-player');
|
||||
|
||||
const observerOptions = {
|
||||
root: null, // viewport
|
||||
threshold: 0.1, // trigger when 10% visible
|
||||
rootMargin: '0px'
|
||||
};
|
||||
|
||||
const observerCallback = (entries) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
// Only use scroll-based logic when on the player tab
|
||||
if (activeTab !== 'player') return;
|
||||
|
||||
setMiniPlayerVisible(!entry.isIntersecting);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, observerOptions);
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(playerContainer);
|
||||
|
||||
// Mini player progress bar click to seek
|
||||
@@ -343,38 +468,6 @@
|
||||
seek(position);
|
||||
});
|
||||
|
||||
// Mini player volume slider
|
||||
const miniVolumeSlider = document.getElementById('mini-volume-slider');
|
||||
miniVolumeSlider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
const volume = parseInt(e.target.value);
|
||||
document.getElementById('mini-volume-display').textContent = `${volume}%`;
|
||||
document.getElementById('volume-display').textContent = `${volume}%`;
|
||||
document.getElementById('volume-slider').value = volume;
|
||||
|
||||
// Throttle volume updates while dragging (update every 16ms via WebSocket)
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
}
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, 16);
|
||||
});
|
||||
|
||||
miniVolumeSlider.addEventListener('change', (e) => {
|
||||
// Clear any pending throttled update
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
}
|
||||
|
||||
// Send final volume update immediately
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
|
||||
});
|
||||
|
||||
// Progress bar click to seek
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
progressBar.addEventListener('click', (e) => {
|
||||
@@ -468,6 +561,12 @@
|
||||
}
|
||||
|
||||
function connectWebSocket(token) {
|
||||
// Clear previous ping interval to prevent stacking
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -518,25 +617,23 @@
|
||||
console.log('Attempting to reconnect...');
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}, 3000);
|
||||
}, WS_RECONNECT_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// Send keepalive ping every 30 seconds
|
||||
setInterval(() => {
|
||||
// Send keepalive ping
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
const dot = document.getElementById('status-dot');
|
||||
|
||||
if (connected) {
|
||||
dot.classList.add('connected');
|
||||
dom.statusDot.classList.add('connected');
|
||||
} else {
|
||||
dot.classList.remove('connected');
|
||||
dom.statusDot.classList.remove('connected');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,28 +643,35 @@
|
||||
|
||||
// Update track info
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
document.getElementById('track-title').textContent = status.title || fallbackTitle;
|
||||
document.getElementById('artist').textContent = status.artist || '';
|
||||
document.getElementById('album').textContent = status.album || '';
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.artist.textContent = status.artist || '';
|
||||
dom.album.textContent = status.album || '';
|
||||
|
||||
// Update mini player info
|
||||
document.getElementById('mini-track-title').textContent = status.title || fallbackTitle;
|
||||
document.getElementById('mini-artist').textContent = status.artist || '';
|
||||
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.miniArtist.textContent = status.artist || '';
|
||||
|
||||
// Update state
|
||||
const previousState = currentState;
|
||||
currentState = status.state;
|
||||
updatePlaybackState(status.state);
|
||||
|
||||
// Update album art
|
||||
const artImg = document.getElementById('album-art');
|
||||
const miniArtImg = document.getElementById('mini-album-art');
|
||||
const artworkUrl = status.album_art_url
|
||||
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}`
|
||||
: "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";
|
||||
// Update album art (skip if same source to avoid redundant network requests)
|
||||
const artworkSource = status.album_art_url || null;
|
||||
|
||||
artImg.src = artworkUrl;
|
||||
miniArtImg.src = artworkUrl;
|
||||
if (artworkSource !== lastArtworkSource) {
|
||||
lastArtworkSource = artworkSource;
|
||||
const artworkUrl = artworkSource
|
||||
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}`
|
||||
: "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";
|
||||
dom.albumArt.src = artworkUrl;
|
||||
dom.miniAlbumArt.src = artworkUrl;
|
||||
if (dom.albumArtGlow) {
|
||||
dom.albumArtGlow.src = artworkSource
|
||||
? artworkUrl
|
||||
: "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";
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (status.duration && status.position !== null) {
|
||||
@@ -583,24 +687,24 @@
|
||||
|
||||
// Update volume
|
||||
if (!isUserAdjustingVolume) {
|
||||
document.getElementById('volume-slider').value = status.volume;
|
||||
document.getElementById('volume-display').textContent = `${status.volume}%`;
|
||||
document.getElementById('mini-volume-slider').value = status.volume;
|
||||
document.getElementById('mini-volume-display').textContent = `${status.volume}%`;
|
||||
dom.volumeSlider.value = status.volume;
|
||||
dom.volumeDisplay.textContent = `${status.volume}%`;
|
||||
dom.miniVolumeSlider.value = status.volume;
|
||||
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
|
||||
}
|
||||
|
||||
// Update mute state
|
||||
updateMuteIcon(status.muted);
|
||||
|
||||
// Update source
|
||||
document.getElementById('source').textContent = status.source || t('player.unknown_source');
|
||||
dom.source.textContent = status.source || t('player.unknown_source');
|
||||
|
||||
// Enable/disable controls based on state
|
||||
const hasMedia = status.state !== 'idle';
|
||||
document.getElementById('btn-play-pause').disabled = !hasMedia;
|
||||
document.getElementById('btn-next').disabled = !hasMedia;
|
||||
document.getElementById('btn-previous').disabled = !hasMedia;
|
||||
document.getElementById('mini-btn-play-pause').disabled = !hasMedia;
|
||||
dom.btnPlayPause.disabled = !hasMedia;
|
||||
dom.btnNext.disabled = !hasMedia;
|
||||
dom.btnPrevious.disabled = !hasMedia;
|
||||
dom.miniBtnPlayPause.disabled = !hasMedia;
|
||||
|
||||
// Start/stop position interpolation based on playback state
|
||||
if (status.state === 'playing' && previousState !== 'playing') {
|
||||
@@ -611,49 +715,50 @@
|
||||
}
|
||||
|
||||
function updatePlaybackState(state) {
|
||||
const stateText = document.getElementById('playback-state');
|
||||
const stateIcon = document.getElementById('state-icon');
|
||||
const playPauseIcon = document.getElementById('play-pause-icon');
|
||||
const miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
|
||||
|
||||
currentPlayState = state;
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
stateText.textContent = t('state.playing');
|
||||
stateIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
dom.playbackState.textContent = t('state.playing');
|
||||
dom.stateIcon.innerHTML = SVG_PLAY;
|
||||
dom.playPauseIcon.innerHTML = SVG_PAUSE;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
|
||||
break;
|
||||
case 'paused':
|
||||
stateText.textContent = t('state.paused');
|
||||
stateIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
dom.playbackState.textContent = t('state.paused');
|
||||
dom.stateIcon.innerHTML = SVG_PAUSE;
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
break;
|
||||
case 'stopped':
|
||||
stateText.textContent = t('state.stopped');
|
||||
stateIcon.innerHTML = '<path d="M6 6h12v12H6z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
dom.playbackState.textContent = t('state.stopped');
|
||||
dom.stateIcon.innerHTML = SVG_STOP;
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
break;
|
||||
default:
|
||||
stateText.textContent = t('state.idle');
|
||||
stateIcon.innerHTML = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
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;
|
||||
document.getElementById('progress-fill').style.width = `${percent}%`;
|
||||
document.getElementById('current-time').textContent = formatTime(position);
|
||||
document.getElementById('total-time').textContent = formatTime(duration);
|
||||
document.getElementById('progress-bar').dataset.duration = duration;
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
|
||||
// Update mini player progress
|
||||
document.getElementById('mini-progress-fill').style.width = `${percent}%`;
|
||||
document.getElementById('mini-current-time').textContent = formatTime(position);
|
||||
document.getElementById('mini-total-time').textContent = formatTime(duration);
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.currentTime.textContent = currentStr;
|
||||
dom.totalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
}
|
||||
|
||||
function startPositionInterpolation() {
|
||||
@@ -665,14 +770,11 @@
|
||||
// Update position every 100ms for smooth animation
|
||||
interpolationInterval = setInterval(() => {
|
||||
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
|
||||
// Calculate elapsed time since last position update
|
||||
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
|
||||
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
|
||||
|
||||
// Update UI with interpolated position
|
||||
updateProgress(interpolatedPosition, currentDuration);
|
||||
}
|
||||
}, 100);
|
||||
}, POSITION_INTERPOLATION_MS);
|
||||
}
|
||||
|
||||
function stopPositionInterpolation() {
|
||||
@@ -683,18 +785,9 @@
|
||||
}
|
||||
|
||||
function updateMuteIcon(muted) {
|
||||
const muteIcon = document.getElementById('mute-icon');
|
||||
const miniMuteIcon = document.getElementById('mini-mute-icon');
|
||||
const mutedPath = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const unmutedPath = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
if (muted) {
|
||||
muteIcon.innerHTML = mutedPath;
|
||||
miniMuteIcon.innerHTML = mutedPath;
|
||||
} else {
|
||||
muteIcon.innerHTML = unmutedPath;
|
||||
miniMuteIcon.innerHTML = unmutedPath;
|
||||
}
|
||||
const path = muted ? SVG_MUTED : SVG_UNMUTED;
|
||||
dom.muteIcon.innerHTML = path;
|
||||
dom.miniMuteIcon.innerHTML = path;
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
@@ -723,10 +816,13 @@
|
||||
try {
|
||||
const response = await fetch(`/api/media/${endpoint}`, options);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
console.error(`Command ${endpoint} failed:`, response.status);
|
||||
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending command ${endpoint}:`, error);
|
||||
showToast(`Connection error: ${endpoint}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,7 +842,10 @@
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
let lastSentVolume = -1;
|
||||
function setVolume(volume) {
|
||||
if (volume === lastSentVolume) return;
|
||||
lastSentVolume = volume;
|
||||
// Use WebSocket for low-latency volume updates
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
|
||||
@@ -788,7 +887,7 @@
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
if (scripts.length === 0) {
|
||||
grid.innerHTML = `<div class="scripts-empty">${t('scripts.no_scripts')}</div>`;
|
||||
grid.innerHTML = `<div class="scripts-empty empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('scripts.no_scripts')}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -855,12 +954,20 @@
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
// Script Management Functions
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
async function loadScriptsTable() {
|
||||
if (_loadScriptsPromise) return _loadScriptsPromise;
|
||||
_loadScriptsPromise = _loadScriptsTableImpl();
|
||||
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
||||
return _loadScriptsPromise;
|
||||
}
|
||||
|
||||
async function _loadScriptsTableImpl() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const tbody = document.getElementById('scriptsTableBody');
|
||||
|
||||
@@ -876,7 +983,7 @@
|
||||
const scriptsList = await response.json();
|
||||
|
||||
if (scriptsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No scripts configured. Click "Add Script" to create one.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -997,6 +1104,9 @@
|
||||
async function saveScript(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
|
||||
const scriptName = isEdit ?
|
||||
@@ -1032,15 +1142,16 @@
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
scriptFormDirty = false; // Reset dirty state before closing
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
// Don't reload manually - WebSocket will trigger it
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,7 +1186,15 @@
|
||||
|
||||
// Callback Management Functions
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
||||
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
||||
return _loadCallbacksPromise;
|
||||
}
|
||||
|
||||
async function _loadCallbacksTableImpl() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const tbody = document.getElementById('callbacksTableBody');
|
||||
|
||||
@@ -1091,7 +1210,7 @@
|
||||
const callbacksList = await response.json();
|
||||
|
||||
if (callbacksList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1201,6 +1320,9 @@
|
||||
async function saveCallback(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
|
||||
const callbackName = document.getElementById('callbackName').value;
|
||||
@@ -1232,7 +1354,7 @@
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
callbackFormDirty = false; // Reset dirty state before closing
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
@@ -1241,6 +1363,8 @@
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,6 +1635,7 @@ function showRootFolders() {
|
||||
|
||||
// Render folders as grid cards
|
||||
const container = document.getElementById('browserGrid');
|
||||
revokeBlobUrls(container);
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
} else if (viewMode === 'compact') {
|
||||
@@ -1662,8 +1787,15 @@ function renderBreadcrumbs(currentPath, parentPath) {
|
||||
});
|
||||
}
|
||||
|
||||
function revokeBlobUrls(container) {
|
||||
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBrowserItems(items) {
|
||||
const container = document.getElementById('browserGrid');
|
||||
revokeBlobUrls(container);
|
||||
// Switch container class based on view mode
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
@@ -1681,7 +1813,7 @@ function renderBrowserList(items, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1774,7 +1906,7 @@ function renderBrowserGrid(items, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1943,6 +2075,10 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if any
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imgElement.src);
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
// Fallback to icon (204 = no thumbnail available)
|
||||
@@ -1964,7 +2100,11 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
let playInProgress = false;
|
||||
|
||||
async function playMediaFile(fileName) {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
@@ -1988,15 +2128,20 @@ async function playMediaFile(fileName) {
|
||||
|
||||
if (!response.ok) throw new Error('Failed to play file');
|
||||
|
||||
const data = await response.json();
|
||||
showToast(t('browser.play_success', { filename: fileName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error playing file:', error);
|
||||
showToast(t('browser.play_error'), 'error');
|
||||
} finally {
|
||||
playInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAllFolder() {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
const btn = document.getElementById('playAllBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token || !currentFolderId) return;
|
||||
@@ -2020,6 +2165,9 @@ async function playAllFolder() {
|
||||
} catch (error) {
|
||||
console.error('Error playing folder:', error);
|
||||
showToast(t('browser.play_all_error'), 'error');
|
||||
} finally {
|
||||
playInProgress = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2108,7 +2256,7 @@ function onBrowserSearch() {
|
||||
browserSearchTimer = setTimeout(() => {
|
||||
browserSearchTerm = term.toLowerCase();
|
||||
applyBrowserSearch();
|
||||
}, 200);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearBrowserSearch() {
|
||||
@@ -2206,7 +2354,7 @@ function initBrowserToolbar() {
|
||||
|
||||
function clearBrowserGrid() {
|
||||
const grid = document.getElementById('browserGrid');
|
||||
grid.innerHTML = `<div class="browser-empty" data-i18n="browser.no_folder_selected">${t('browser.no_folder_selected')}</div>`;
|
||||
grid.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}</div>`;
|
||||
document.getElementById('breadcrumb').innerHTML = '';
|
||||
document.getElementById('browserPagination').style.display = 'none';
|
||||
document.getElementById('playAllBtn').style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user