diff --git a/webui/static/core.js b/webui/static/core.js index 520afe6a..9a6f1ef8 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -381,7 +381,7 @@ function initializeWebSocket() { } socket = io({ - transports: ['polling', 'websocket'], + transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 70091e06..b32cb812 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5784,12 +5784,12 @@ function _gsUpdateVisibility() { const bar = document.getElementById('gsearch-bar'); const aura = document.getElementById('gsearch-aura'); if (!bar) return; - // Hide on the Search page where the unified search already exists. Accept the - // legacy 'downloads' id for callers that predate the page rename. - const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); - bar.style.display = onSearchPage ? 'none' : ''; - if (aura) aura.classList.toggle('hidden', onSearchPage); - if (onSearchPage && _gsState.active) _gsDeactivate(); + // Hide on pages where global search doesn't belong. + const _gsHidePages = new Set(['search', 'downloads', 'settings', 'help', 'issues', 'import']); + const onHidePage = typeof currentPage !== 'undefined' && _gsHidePages.has(currentPage); + bar.style.display = onHidePage ? 'none' : ''; + if (aura) aura.classList.toggle('hidden', onHidePage); + if (onHidePage && _gsState.active) _gsDeactivate(); } function _gsDeactivate() { diff --git a/webui/static/init.js b/webui/static/init.js index 80fd8564..fe4779c5 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -1,6 +1,18 @@ // INITIALIZATION // =============================== let navigationEpoch = 0; +let _optimisticNavPageId = null; + +// Schedule heavy per-page init during browser idle time so navigation paints and +// the page becomes scrollable first. timeout caps the delay so content still loads +// promptly. Falls back to a macrotask in browsers without requestIdleCallback. +function _scheduleHeavyInit(fn) { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(fn, { timeout: 200 }); + } else { + setTimeout(fn, 0); + } +} function notifyPageWillChange(nextPageId) { const fromPageId = typeof currentPage === 'string' ? currentPage : null; @@ -175,13 +187,35 @@ function applyReduceEffects(enabled) { } else { document.body.classList.remove('reduce-effects'); } + window._reduceEffectsActive = enabled; localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0'); + + // Reduce Visual Effects is a full performance switch: also halt the canvas + // animation loops (particles + worker orbs), not just CSS effects. + const pcanvas = document.getElementById('page-particles-canvas'); + if (enabled) { + if (window.pageParticles) window.pageParticles.stop(); + if (pcanvas) pcanvas.style.display = 'none'; + if (window.workerOrbs) window.workerOrbs.setPage('_disabled'); + } else { + // Restore only what the user's own toggles still allow. + const activePage = document.querySelector('.page.active'); + const activeId = activePage ? activePage.id.replace('-page', '') : null; + if (window._particlesEnabled !== false) { + if (pcanvas) pcanvas.style.display = ''; + if (window.pageParticles && activeId) window.pageParticles.setPage(activeId); + } + if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) { + window.workerOrbs.setPage(activeId); + } + } } // Bootstrap accent and reduce-effects from localStorage instantly (prevents flash) (function () { if (localStorage.getItem('soulsync-reduce-effects') === '1') { document.body.classList.add('reduce-effects'); + window._reduceEffectsActive = true; } const saved = localStorage.getItem('soulsync-accent'); if (saved) applyAccentColor(saved); @@ -2314,6 +2348,15 @@ function navigateToPage(pageId, options = {}) { if (route?.kind === 'react') { showReactHost(pageId); setActivePageChrome(pageId); + } else if (route?.kind === 'legacy' && pageId !== 'artist-detail') { + // Show legacy page immediately — don't wait for TanStack Router's async cycle + showLegacyPage(pageId); + setActivePageChrome(pageId); + _optimisticNavPageId = pageId; + // Defer data loading until the browser is idle, so the page paints AND + // becomes scrollable before heavy sync init (settings form wiring, etc.) + // runs. Falls back to a macrotask where requestIdleCallback is missing. + _scheduleHeavyInit(() => loadPageData(pageId)); } return router.navigateToPage(pageId, { replace: options.replace === true, @@ -2413,12 +2456,20 @@ async function loadPageData(pageId) { initExplorer(); break; case 'settings': - initializeSettings(); - switchSettingsTab('connections'); - await loadSettingsData(); - await loadQualityProfile(); - loadApiKeys(); - loadBlacklistCount(); + // Suppress auto-save while the form is being populated, so opening + // Settings no longer fires a spurious full save (4 POSTs + backend + // service re-init) on every visit. + window._suppressSettingsAutoSave = true; + try { + initializeSettings(); + switchSettingsTab('connections'); + await loadSettingsData(); + await loadQualityProfile(); + loadApiKeys(); + loadBlacklistCount(); + } finally { + window._suppressSettingsAutoSave = false; + } break; case 'hydrabase': // Check connection status and pre-fill saved credentials diff --git a/webui/static/particles.js b/webui/static/particles.js index a390db9c..2b446806 100644 --- a/webui/static/particles.js +++ b/webui/static/particles.js @@ -69,13 +69,32 @@ return result; } + // Cache viewport dims so initLayer/setPreset (runs on every page nav) doesn't + // force a synchronous layout flush by reading clientWidth/clientHeight each time. + let _vpW = 0, _vpH = 0; + function measureViewport() { + _vpW = canvas.clientWidth; + _vpH = canvas.clientHeight; + } function resize() { - const dpr = 1; // keep 1:1 for performance - canvas.width = canvas.clientWidth * dpr; - canvas.height = canvas.clientHeight * dpr; + if (_vpW === 0) measureViewport(); // first run only + if (canvas.width !== _vpW || canvas.height !== _vpH) { + canvas.width = _vpW; + canvas.height = _vpH; + } } - window.addEventListener('resize', resize); + let _resizeRaf = 0; + window.addEventListener('resize', () => { + // Debounce to a frame; re-measure (reads layout) then apply. + if (_resizeRaf) return; + _resizeRaf = requestAnimationFrame(() => { + _resizeRaf = 0; + measureViewport(); + resize(); + }); + }); + measureViewport(); resize(); // ── Preset Definitions ── @@ -2061,9 +2080,31 @@ return { preset, particles: parts, extras: layerExtras, name: presetName }; } - function loop() { + let _lastFrameTime = 0; + const _targetFps = 30; + const _frameInterval = 1000 / _targetFps; + + // Pause particle drawing during active scroll (timestamp threshold, ms). + let _scrollPauseUntil = 0; + (function attachScrollPause() { + const scroller = document.querySelector('.main-content') || window; + scroller.addEventListener('scroll', () => { + // requestAnimationFrame timestamps share the performance.now() clock. + _scrollPauseUntil = performance.now() + 180; + }, { passive: true }); + })(); + + function loop(timestamp) { animFrame = requestAnimationFrame(loop); + // Give the scroll its full frame budget: while the user is actively + // scrolling, skip the full-viewport canvas redraw (particles freeze on + // their last frame, then resume on scroll-idle). Eliminates scroll jank. + if (timestamp < _scrollPauseUntil) return; + + if (timestamp - _lastFrameTime < _frameInterval) return; + _lastFrameTime = timestamp - ((timestamp - _lastFrameTime) % _frameInterval); + const w = canvas.width, h = canvas.height; if (w === 0 || h === 0) { resize(); return; } @@ -2286,6 +2327,8 @@ // Listen for page changes from script.js window.pageParticles = { setPage(pageId) { + // Reduce Visual Effects performance mode halts the loop entirely. + if (window._reduceEffectsActive) { stop(); return; } const presetName = PAGE_PRESETS[pageId] || 'none'; setPreset(presetName); }, @@ -2294,7 +2337,7 @@ // Auto-start for initial page (respect particles toggle) requestAnimationFrame(() => { - if (window._particlesEnabled === false) return; + if (window._particlesEnabled === false || window._reduceEffectsActive) return; const activePage = document.querySelector('.page.active'); if (activePage) { const pageId = activePage.id.replace('-page', ''); diff --git a/webui/static/settings.js b/webui/static/settings.js index d9a06c30..83472307 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -41,6 +41,10 @@ async function copyAddress(address, cryptoName) { let settingsAutoSaveTimer = null; function debouncedAutoSaveSettings() { + // Ignore changes made while the page is programmatically populating its + // fields on load — those aren't user edits and must not trigger a full + // save (which re-initializes every backend service client). + if (window._suppressSettingsAutoSave) return; if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000); } @@ -138,9 +142,14 @@ function handleMetadataSourceChange(event) { sanitizeMetadataSourceSelection({ quiet: false }); } +let _settingsInitialized = false; function initializeSettings() { // This function is called when the settings page is loaded. // It attaches event listeners to all interactive elements on the page. + // Listeners are stable for the page lifetime, so wiring them once avoids + // re-scanning the ~960-node settings subtree on every revisit (scroll jank). + if (_settingsInitialized) return; + _settingsInitialized = true; // Accent color listeners (live preview + custom picker toggle) initAccentColorListeners(); diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index af9af731..1f93f2c3 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -52,8 +52,11 @@ function setActivePageChrome(pageId) { downloadSidebar.classList.add('hidden'); } } - if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId); - if (window.workerOrbs) window.workerOrbs.setPage(pageId); + // Defer to next frame so the page switch paints before particle/orb reinitialization + requestAnimationFrame(() => { + if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId); + if (window.workerOrbs) window.workerOrbs.setPage(pageId); + }); } function showReactHost(pageId) { @@ -83,6 +86,14 @@ function activateLegacyPath(pathname) { return; } + // If the page was already shown optimistically (forward nav), skip re-activation + // to avoid a duplicate data load. Back-button nav always has _optimisticNavPageId = null. + if (typeof _optimisticNavPageId !== 'undefined' && _optimisticNavPageId === targetPage) { + _optimisticNavPageId = null; + return; + } + _optimisticNavPageId = null; + notifyPageWillChange(targetPage); activatePage(targetPage, { forceReload: true }); } @@ -241,4 +252,15 @@ function _isModifiedLinkClick(event) { window.addEventListener('popstate', syncActivePageFromLocation); document.addEventListener('click', _handleShellLinkClick, true); + +// Fire nav on pointerdown (fires on press, 100-200ms before click) for instant sidebar response. +// navigateToPage's early-return guard (pageId === currentPage) prevents double-navigation on click. +document.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || _isModifiedLinkClick(event)) return; + const btn = event.target?.closest?.('.nav-button[data-page]'); + if (!btn) return; + const pageId = btn.getAttribute('data-page'); + if (pageId) void navigateToPage(pageId); +}, true); + window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT)); diff --git a/webui/static/style.css b/webui/static/style.css index 575ce2c9..a581f78f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -133,6 +133,13 @@ body { z-index: 0; } +/* In performance mode the aura loses its blur + animation, leaving two hard + static circles — hide them entirely instead. */ +body.reduce-effects .sidebar::before, +body.reduce-effects .sidebar::after { + display: none !important; +} + @keyframes sidebar-orb-1 { 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.5; } 25% { transform: translate(30px, 80px) scale(1.2); opacity: 1; } @@ -388,7 +395,7 @@ body { gap: 14px; text-decoration: none; padding: 0 16px; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: background 0.1s ease, border-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease, color 0.1s ease; font-family: 'SF Pro Text', -apple-system, sans-serif; font-size: 12px; font-weight: 500; @@ -408,14 +415,14 @@ body { border-radius: 0 3px 3px 0; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))); box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.6); - transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: height 0.12s ease; } .nav-button.active::before { height: 60%; } -.nav-button:hover { +body:not(.reduce-effects) .nav-button:hover { background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%); @@ -426,20 +433,32 @@ body { inset 0 1px 0 rgba(255, 255, 255, 0.06); } +.nav-button:active:not(.active) { + background: linear-gradient(135deg, + rgba(var(--accent-rgb), 0.14) 0%, + rgba(var(--accent-rgb), 0.10) 50%, + rgba(var(--accent-rgb), 0.06) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.2); + box-shadow: + 0 4px 16px rgba(var(--accent-rgb), 0.2), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + transition: none; +} + .nav-button.active { background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.10) 50%, rgba(var(--accent-rgb), 0.06) 100%); border: 1px solid rgba(var(--accent-rgb), 0.2); - transform: translateX(6px); box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.2), 0 2px 8px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1); } -.nav-button.active:hover { +body:not(.reduce-effects) .nav-button.active:hover { background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.20) 0%, rgba(var(--accent-rgb), 0.16) 50%, @@ -466,7 +485,7 @@ body { rgba(255, 255, 255, 0.03) 100%); border: 1px solid rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.7); - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease, box-shadow 0.1s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.06); @@ -476,7 +495,7 @@ body { width: 22px; height: 22px; pointer-events: none; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 0.1s ease, opacity 0.1s ease; color: rgba(var(--accent-rgb), 0.7); } @@ -4925,6 +4944,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { border: 1px solid rgba(70, 70, 76, 0.5); margin-bottom: 8px; padding: 16px; + content-visibility: auto; + contain-intrinsic-size: auto 88px; display: flex; align-items: center; justify-content: space-between; @@ -20370,6 +20391,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Wishlist Delete Buttons */ .wishlist-track-item { position: relative; + content-visibility: auto; + contain-intrinsic-size: auto 64px; } .wishlist-delete-btn { @@ -26406,6 +26429,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { border: 1px solid rgba(255, 255, 255, 0.06); padding: 0; cursor: pointer; + content-visibility: auto; + contain-intrinsic-size: auto 240px; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); @@ -33576,6 +33601,10 @@ div.artist-hero-badge { .discover-section { margin-bottom: 50px; padding: 0 20px; + /* Skip layout/paint of off-screen shelves so entering Discover and scrolling + stays cheap. 'auto' lets the browser remember real size after first render. */ + content-visibility: auto; + contain-intrinsic-size: auto 340px; } .discover-section-header { @@ -58643,17 +58672,23 @@ tr:hover .enhanced-track-actions-group { opacity: 1; } } .blacklist-entry-remove:hover { background: rgba(239, 83, 80, 0.12); color: #ef5350; } -/* ── Reduce Visual Effects ── Disables GPU-heavy properties globally */ +/* ── Reduce Visual Effects ── Full performance mode: disables GPU-heavy + properties and halts decorative animation globally. */ body.reduce-effects *, body.reduce-effects *::before, body.reduce-effects *::after { - animation-duration: 0s !important; - animation-delay: 0s !important; + animation: none !important; transition-duration: 0s !important; transition-delay: 0s !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: none !important; + filter: none !important; +} + +/* Kill the background particle canvas entirely in performance mode */ +body.reduce-effects #page-particles-canvas { + display: none !important; } /* ============================================ diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 0b4084ad..d2f2db1a 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -7495,35 +7495,27 @@ async function initializeToolsPage() { } async function loadDashboardData() { - // Initial load of wishlist count - await updateWishlistCount(); - - // Start periodic refresh of wishlist count (every 10 seconds) + // Start periodic refreshers up front (independent of the initial loads). stopWishlistCountPolling(); // Ensure no duplicates wishlistCountInterval = setInterval(updateWishlistCount, 10000); - - // Initial load of service status, system statistics, and library status - await fetchAndUpdateServiceStatus(); - await fetchAndUpdateSystemStats(); - await fetchAndUpdateDbStats(); - - // Service status is already polled globally (line 311) - // System stats polling kept here (dashboard-specific) - setInterval(fetchAndUpdateSystemStats, 10000); - - // Initial load of activity feed - await fetchAndUpdateActivityFeed(); - - // Start periodic refresh of activity feed (every 2 seconds for responsiveness) - setInterval(fetchAndUpdateActivityFeed, 2000); - - // Start periodic toast checking (every 3 seconds) + setInterval(fetchAndUpdateSystemStats, 10000); // dashboard-specific (service status polled globally) + setInterval(fetchAndUpdateActivityFeed, 2000); // responsive activity feed setInterval(checkForActivityToasts, 3000); - // Check for any active download processes that need rehydration - await checkForActiveProcesses(); + // Fire all independent initial loads in parallel instead of sequentially. + // Sequential awaits meant 6 back-to-back round-trips, each triggering its own + // reflow — the layout kept shifting for ~1-2s, which made the page feel + // unscrollable. Concurrent loads collapse that into a single settle. + await Promise.all([ + updateWishlistCount(), + fetchAndUpdateServiceStatus(), + fetchAndUpdateSystemStats(), + fetchAndUpdateDbStats(), + fetchAndUpdateActivityFeed(), + checkForActiveProcesses(), + ]); - // Populate the Active Downloads dashboard section with any existing downloads + // Render existing downloads once active processes are known. updateDashboardDownloads(); // Automatic wishlist processing now runs server-side diff --git a/webui/static/worker-orbs.js b/webui/static/worker-orbs.js index 3e137429..2b4941a5 100644 --- a/webui/static/worker-orbs.js +++ b/webui/static/worker-orbs.js @@ -331,6 +331,14 @@ let frameCount = 0; + let _scrollPauseUntil = 0; + (function attachScrollPause() { + const scroller = document.querySelector('.main-content') || window; + scroller.addEventListener('scroll', () => { + _scrollPauseUntil = performance.now() + 180; + }, { passive: true }); + })(); + function startLoop() { if (animFrame) return; tick(); @@ -347,6 +355,9 @@ animFrame = requestAnimationFrame(tick); if (!canvas || !ctx) return; + // Yield the frame to active scrolling (orbs freeze, resume on idle). + if (performance.now() < _scrollPauseUntil) return; + frameCount++; const time = frameCount / 60; const w = canvas.width; @@ -582,7 +593,7 @@ // ── Page awareness ── function isEnabled() { - return window._workerOrbsEnabled !== false; + return window._workerOrbsEnabled !== false && !window._reduceEffectsActive; } function setPage(pageId) {