Merge pull request #783 from nick2000713/perf/webui-navigation-scroll

perf(webui): faster navigation, smoother scroll, fix spurious settings save on load
This commit is contained in:
BoulderBadgeDad 2026-06-03 21:06:33 -07:00 committed by GitHub
commit 4c65f403e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 219 additions and 56 deletions

View file

@ -382,7 +382,7 @@ function initializeWebSocket() {
}
socket = io({
transports: ['polling', 'websocket'],
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,

View file

@ -5802,12 +5802,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() {

View file

@ -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

View file

@ -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', '');

View file

@ -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();

View file

@ -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));

View file

@ -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;
@ -20446,6 +20467,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 {
@ -26482,6 +26505,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);
@ -33658,6 +33683,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 {
@ -58725,17 +58754,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;
}
/* ============================================

View file

@ -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

View file

@ -439,6 +439,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();
@ -455,6 +463,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;
@ -840,7 +851,7 @@
// ── Page awareness ──
function isEnabled() {
return window._workerOrbsEnabled !== false;
return window._workerOrbsEnabled !== false && !window._reduceEffectsActive;
}
// ── Real telemetry → pulses ──