From c2fd265b491f8f7207fba468ccc22b0a3d831b67 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Wed, 4 Nov 2026 17:00:16 +0100 Subject: [PATCH] perf(launcher): reduce background resource usage Make the custom cursor event-driven, pause CSS, theme video, and Lottie work while the launcher is hidden or unfocused, and reduce presence polling while osu is running. --- src/app.css | 7 ++ .../components/theme/ThemeLottieLayer.svelte | 38 +++++++-- .../ui/animated-bg/AnimatedBg.svelte | 1 + .../components/ui/osu-cursor/OsuCursor.svelte | 80 +++++++++++++++---- src/lib/theme/themeMedia.ts | 23 +++++- src/lib/utils.ts | 2 +- src/routes/+layout.svelte | 32 ++++++++ src/screens/Launch.svelte | 19 ++++- 8 files changed, 171 insertions(+), 31 deletions(-) diff --git a/src/app.css b/src/app.css index 23dfe7c..c780a41 100644 --- a/src/app.css +++ b/src/app.css @@ -174,6 +174,13 @@ } } +/* Pause continuous visual work while the launcher is hidden or in the background. */ +main.app-inactive *, +main.app-inactive *::before, +main.app-inactive *::after { + animation-play-state: paused !important; +} + /* Suppress browser and WebKit native media controls and overlay play buttons on theme videos */ video::-webkit-media-controls, video::-webkit-media-controls-enclosure, diff --git a/src/lib/components/theme/ThemeLottieLayer.svelte b/src/lib/components/theme/ThemeLottieLayer.svelte index c518be5..eac4c22 100644 --- a/src/lib/components/theme/ThemeLottieLayer.svelte +++ b/src/lib/components/theme/ThemeLottieLayer.svelte @@ -17,12 +17,38 @@ let container = $state(undefined); let animation: AnimationItem | undefined; let reducedMotion = false; + let windowActive = true; + + function syncPlayback() { + if (!animation) return; + animation.loop = loop; + animation.setSpeed(speed); + if (autoplay && !reducedMotion && windowActive && document.visibilityState === 'visible') { + animation.play(); + } else { + animation.pause(); + } + } onMount(() => { if (!container || !src) return; reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; let cancelled = false; + const onBlur = () => { + windowActive = false; + syncPlayback(); + }; + const onFocus = () => { + windowActive = true; + syncPlayback(); + }; + const onVisibilityChange = () => syncPlayback(); + + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVisibilityChange); + const start = (animationData?: unknown) => { if (cancelled || !container) return; try { @@ -30,7 +56,8 @@ container, renderer: 'svg', loop, - autoplay: autoplay && !reducedMotion, + autoplay: + autoplay && !reducedMotion && windowActive && document.visibilityState === 'visible', ...(animationData ? { animationData } : { path: src }), rendererSettings: { preserveAspectRatio: 'xMidYMid meet' }, }); @@ -52,15 +79,14 @@ return () => { cancelled = true; + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVisibilityChange); }; }); $effect(() => { - if (!animation) return; - animation.loop = loop; - animation.setSpeed(speed); - if (autoplay && !reducedMotion) animation.play(); - else animation.pause(); + syncPlayback(); }); onDestroy(() => { diff --git a/src/lib/components/ui/animated-bg/AnimatedBg.svelte b/src/lib/components/ui/animated-bg/AnimatedBg.svelte index 046429e..192360a 100644 --- a/src/lib/components/ui/animated-bg/AnimatedBg.svelte +++ b/src/lib/components/ui/animated-bg/AnimatedBg.svelte @@ -48,6 +48,7 @@ opacity:{$reduceAnimations ? 0 : s.opacity}; animation-duration:{s.duration}s; animation-delay:-{s.delay}s; + animation-play-state:{$reduceAnimations ? 'paused' : 'running'}; " > {/each} diff --git a/src/lib/components/ui/osu-cursor/OsuCursor.svelte b/src/lib/components/ui/osu-cursor/OsuCursor.svelte index 35476ac..698c071 100644 --- a/src/lib/components/ui/osu-cursor/OsuCursor.svelte +++ b/src/lib/components/ui/osu-cursor/OsuCursor.svelte @@ -34,6 +34,7 @@ let velocityAccumX = 0; let velocityAccumY = 0; let rotateSpringAnim: ReturnType | null = null; + let scheduleCursorFrame = () => {}; let cursor: HTMLElement; let cursorRotateWrapper: HTMLDivElement; @@ -41,20 +42,15 @@ let cursorAdditive: HTMLImageElement; function isInteractive(el: Element | null): boolean { - while (el) { - const tag = el.tagName.toLowerCase(); - const role = el.getAttribute('role'); - const computed = getComputedStyle(el); - if ( - ['button', 'a', 'input', 'select', 'textarea', 'label', 'option'].includes(tag) || - (role && ['button', 'link', 'checkbox', 'combobox', 'menuitem', 'option'].includes(role)) || - computed.cursor === 'pointer' - ) { - return true; - } - el = el.parentElement; + if (!el) return false; + if ( + el.closest( + 'button, a, input, select, textarea, label, option, [role="button"], [role="link"], [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"]' + ) + ) { + return true; } - return false; + return getComputedStyle(el).cursor === 'pointer'; } function dampContinuously(current: number, target: number, halfLife: number, dt: number): number { @@ -133,6 +129,7 @@ } } + scheduleCursorFrame(); lastMouseX = targetX; lastMouseY = targetY; }; @@ -147,6 +144,7 @@ lastVelocityTime = performance.now(); isMouseDown = true; + scheduleCursorFrame(); if (rotateSpringAnim) { rotateSpringAnim.pause(); @@ -213,12 +211,17 @@ document.addEventListener('mouseleave', handleMouseLeave); document.addEventListener('mouseenter', handleMouseEnter); - let animationFrameId: number; + let animationFrameId: number | undefined; let lastFrameTime = performance.now(); + let paused = false; const tick = (now: number) => { + animationFrameId = undefined; + if (paused) return; + const dt = Math.min(Math.max(now - lastFrameTime, 0), 64); lastFrameTime = now; + let needsAnotherFrame = false; if (hasMoved && cursor) { if (!smoothCursor) { @@ -229,6 +232,8 @@ const halfLife = $cursorSmoothness > 50 ? 14 : Math.max(1, $cursorSmoothness || 14); currentX = dampContinuously(currentX, targetX, halfLife, dt); currentY = dampContinuously(currentY, targetY, halfLife, dt); + needsAnotherFrame = + Math.abs(targetX - currentX) >= 0.05 || Math.abs(targetY - currentY) >= 0.05; } if (Math.abs(targetX - currentX) < 0.05) currentX = targetX; @@ -244,19 +249,62 @@ const rotFactor = 1 - Math.pow(0.5, dt / 25); currentDegrees += (targetDegrees - currentDegrees) * rotFactor; cursorRotateWrapper.style.transform = `rotate(${currentDegrees}deg)`; + needsAnotherFrame = true; } } + if (needsAnotherFrame) { + animationFrameId = requestAnimationFrame(tick); + } + }; + + scheduleCursorFrame = () => { + if (paused || animationFrameId !== undefined) return; + lastFrameTime = performance.now(); animationFrameId = requestAnimationFrame(tick); }; - animationFrameId = requestAnimationFrame(tick); + const suspendCursor = () => { + paused = true; + if (animationFrameId !== undefined) { + cancelAnimationFrame(animationFrameId); + animationFrameId = undefined; + } + if (cursor) cursor.style.opacity = '0'; + }; + + const resumeCursor = () => { + if (!paused) return; + paused = false; + lastFrameTime = performance.now(); + if (!hasMoved) return; + if (cursor) cursor.style.opacity = '1'; + scheduleCursorFrame(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') suspendCursor(); + else if (document.hasFocus()) resumeCursor(); + }; + const onWindowBlur = () => suspendCursor(); + const onWindowFocus = () => { + if (document.visibilityState !== 'hidden') resumeCursor(); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', onWindowBlur); + window.addEventListener('focus', onWindowFocus); + onVisibilityChange(); return () => { document.documentElement.classList.remove('hiddenCursor'); document.removeEventListener('mouseleave', handleMouseLeave); document.removeEventListener('mouseenter', handleMouseEnter); - cancelAnimationFrame(animationFrameId); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', onWindowBlur); + window.removeEventListener('focus', onWindowFocus); + scheduleCursorFrame = () => {}; + suspendCursor(); if (rotateSpringAnim) { rotateSpringAnim.pause(); } diff --git a/src/lib/theme/themeMedia.ts b/src/lib/theme/themeMedia.ts index 669cecc..c8e0488 100644 --- a/src/lib/theme/themeMedia.ts +++ b/src/lib/theme/themeMedia.ts @@ -532,12 +532,13 @@ export function themeMediaSuspension(node: HTMLElement) { /** * Manage background video playback across document visibility changes. - * Videos continue playing when the launcher window loses focus. Muted background - * videos pause only when the document is hidden/minimized to save GPU decoding - * cycles without interrupting theme music or audio. + * Muted background videos pause when the document is hidden/minimized or the + * launcher loses focus, which saves GPU decoding cycles behind a running game + * without interrupting unmuted theme music or audio. */ export function themeVideoLifecycle(node: HTMLVideoElement) { let pausedByLifecycle = false; + let focused = true; const pause = () => { if (node.paused) return; @@ -551,12 +552,22 @@ export function themeVideoLifecycle(node: HTMLVideoElement) { playThemeMedia(node); }; + const onBlur = () => { + focused = false; + sync(); + }; + const onFocus = () => { + focused = true; + sync(); + }; const sync = () => { if (!node.isConnected) { document.removeEventListener('visibilitychange', sync); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); return; } - if (document.visibilityState === 'visible') { + if (document.visibilityState === 'visible' && focused) { resume(); } else if (node.muted || node.volume === 0) { pause(); @@ -564,11 +575,15 @@ export function themeVideoLifecycle(node: HTMLVideoElement) { }; document.addEventListener('visibilitychange', sync); + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); sync(); return { destroy() { document.removeEventListener('visibilitychange', sync); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); }, }; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8d4f2bb..84c69fb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -133,7 +133,7 @@ export const urlIsValidImage = async (url: string) => { const request = await fetch(url); if (!request.ok) return false; const contentType = request.headers.get('content-type'); - return contentType?.startsWith('image/'); + return contentType?.startsWith('image/') === true; } catch { return false; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 07a8905..b717617 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -71,6 +71,37 @@ let { children } = $props(); + function appActivity(node: HTMLElement) { + let focused = true; + + const sync = () => { + node.classList.toggle('app-inactive', document.hidden || !focused); + }; + const onBlur = () => { + focused = false; + sync(); + }; + const onFocus = () => { + focused = true; + sync(); + }; + const onVisibilityChange = () => sync(); + + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVisibilityChange); + sync(); + + return { + destroy() { + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVisibilityChange); + node.classList.remove('app-inactive'); + }, + }; + } + let unlistenEditorState: (() => void) | undefined; let unlistenRequestEditorData: (() => void) | undefined; let unlistenPresenceEditorState: (() => void) | undefined; @@ -552,6 +583,7 @@
(); const isPresenceConnected = await presence.isConnected(); @@ -1592,8 +1593,10 @@ return; } if ($currentUser) { - const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats'); - const userStatus = await ezppfarm.getUserStatus($currentUser.id); + const [userStats, userStatus] = await Promise.all([ + ezppfarm.getUserInfo($currentUser.id, 'stats'), + ezppfarm.getUserStatus($currentUser.id), + ]); if (userStatus?.player_status.online) { let actionName = 'Idle...'; const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode); @@ -1648,7 +1651,15 @@ if (beatmap !== null) { beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`; const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/list@2x.jpg`; - const isValidImage = await urlIsValidImage(coverCandidate); + const cachedCoverValidity = beatmapCoverValidity.get(coverCandidate); + let isValidImage: boolean; + if (cachedCoverValidity === undefined) { + isValidImage = await urlIsValidImage(coverCandidate); + if (beatmapCoverValidity.size >= 32) beatmapCoverValidity.clear(); + beatmapCoverValidity.set(coverCandidate, isValidImage); + } else { + isValidImage = cachedCoverValidity; + } if (isValidImage) beatmapCoverImage = coverCandidate; } @@ -1678,7 +1689,7 @@ } } } - }, 1000 * 2); + }, 5000); } await runOsu(osuPath, $patch, $gameOptimization);