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.
This commit is contained in:
HorizonCode committed 2026-11-04 17:00:16 +01:00
1 parent 687ab26752
commit c2fd265b49
8 files changed
+171 -31

No files matched your search

+7
View File
@@ -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 */ /* Suppress browser and WebKit native media controls and overlay play buttons on theme videos */
video::-webkit-media-controls, video::-webkit-media-controls,
video::-webkit-media-controls-enclosure, video::-webkit-media-controls-enclosure,
@@ -17,12 +17,38 @@
let container = $state<HTMLDivElement | undefined>(undefined); let container = $state<HTMLDivElement | undefined>(undefined);
let animation: AnimationItem | undefined; let animation: AnimationItem | undefined;
let reducedMotion = false; 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(() => { onMount(() => {
if (!container || !src) return; if (!container || !src) return;
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
let cancelled = 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) => { const start = (animationData?: unknown) => {
if (cancelled || !container) return; if (cancelled || !container) return;
try { try {
@@ -30,7 +56,8 @@
container, container,
renderer: 'svg', renderer: 'svg',
loop, loop,
autoplay: autoplay && !reducedMotion, autoplay:
autoplay && !reducedMotion && windowActive && document.visibilityState === 'visible',
...(animationData ? { animationData } : { path: src }), ...(animationData ? { animationData } : { path: src }),
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' }, rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
}); });
@@ -52,15 +79,14 @@
return () => { return () => {
cancelled = true; cancelled = true;
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
}; };
}); });
$effect(() => { $effect(() => {
if (!animation) return; syncPlayback();
animation.loop = loop;
animation.setSpeed(speed);
if (autoplay && !reducedMotion) animation.play();
else animation.pause();
}); });
onDestroy(() => { onDestroy(() => {
@@ -48,6 +48,7 @@
opacity:{$reduceAnimations ? 0 : s.opacity}; opacity:{$reduceAnimations ? 0 : s.opacity};
animation-duration:{s.duration}s; animation-duration:{s.duration}s;
animation-delay:-{s.delay}s; animation-delay:-{s.delay}s;
animation-play-state:{$reduceAnimations ? 'paused' : 'running'};
" "
></div> ></div>
{/each} {/each}
@@ -34,6 +34,7 @@
let velocityAccumX = 0; let velocityAccumX = 0;
let velocityAccumY = 0; let velocityAccumY = 0;
let rotateSpringAnim: ReturnType<typeof animate> | null = null; let rotateSpringAnim: ReturnType<typeof animate> | null = null;
let scheduleCursorFrame = () => {};
let cursor: HTMLElement; let cursor: HTMLElement;
let cursorRotateWrapper: HTMLDivElement; let cursorRotateWrapper: HTMLDivElement;
@@ -41,20 +42,15 @@
let cursorAdditive: HTMLImageElement; let cursorAdditive: HTMLImageElement;
function isInteractive(el: Element | null): boolean { function isInteractive(el: Element | null): boolean {
while (el) { if (!el) return false;
const tag = el.tagName.toLowerCase(); if (
const role = el.getAttribute('role'); el.closest(
const computed = getComputedStyle(el); 'button, a, input, select, textarea, label, option, [role="button"], [role="link"], [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"]'
if ( )
['button', 'a', 'input', 'select', 'textarea', 'label', 'option'].includes(tag) || ) {
(role && ['button', 'link', 'checkbox', 'combobox', 'menuitem', 'option'].includes(role)) || return true;
computed.cursor === 'pointer'
) {
return true;
}
el = el.parentElement;
} }
return false; return getComputedStyle(el).cursor === 'pointer';
} }
function dampContinuously(current: number, target: number, halfLife: number, dt: number): number { function dampContinuously(current: number, target: number, halfLife: number, dt: number): number {
@@ -133,6 +129,7 @@
} }
} }
scheduleCursorFrame();
lastMouseX = targetX; lastMouseX = targetX;
lastMouseY = targetY; lastMouseY = targetY;
}; };
@@ -147,6 +144,7 @@
lastVelocityTime = performance.now(); lastVelocityTime = performance.now();
isMouseDown = true; isMouseDown = true;
scheduleCursorFrame();
if (rotateSpringAnim) { if (rotateSpringAnim) {
rotateSpringAnim.pause(); rotateSpringAnim.pause();
@@ -213,12 +211,17 @@
document.addEventListener('mouseleave', handleMouseLeave); document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseenter', handleMouseEnter); document.addEventListener('mouseenter', handleMouseEnter);
let animationFrameId: number; let animationFrameId: number | undefined;
let lastFrameTime = performance.now(); let lastFrameTime = performance.now();
let paused = false;
const tick = (now: number) => { const tick = (now: number) => {
animationFrameId = undefined;
if (paused) return;
const dt = Math.min(Math.max(now - lastFrameTime, 0), 64); const dt = Math.min(Math.max(now - lastFrameTime, 0), 64);
lastFrameTime = now; lastFrameTime = now;
let needsAnotherFrame = false;
if (hasMoved && cursor) { if (hasMoved && cursor) {
if (!smoothCursor) { if (!smoothCursor) {
@@ -229,6 +232,8 @@
const halfLife = $cursorSmoothness > 50 ? 14 : Math.max(1, $cursorSmoothness || 14); const halfLife = $cursorSmoothness > 50 ? 14 : Math.max(1, $cursorSmoothness || 14);
currentX = dampContinuously(currentX, targetX, halfLife, dt); currentX = dampContinuously(currentX, targetX, halfLife, dt);
currentY = dampContinuously(currentY, targetY, 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; if (Math.abs(targetX - currentX) < 0.05) currentX = targetX;
@@ -244,19 +249,62 @@
const rotFactor = 1 - Math.pow(0.5, dt / 25); const rotFactor = 1 - Math.pow(0.5, dt / 25);
currentDegrees += (targetDegrees - currentDegrees) * rotFactor; currentDegrees += (targetDegrees - currentDegrees) * rotFactor;
cursorRotateWrapper.style.transform = `rotate(${currentDegrees}deg)`; 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);
}; };
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 () => { return () => {
document.documentElement.classList.remove('hiddenCursor'); document.documentElement.classList.remove('hiddenCursor');
document.removeEventListener('mouseleave', handleMouseLeave); document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseenter', handleMouseEnter); document.removeEventListener('mouseenter', handleMouseEnter);
cancelAnimationFrame(animationFrameId); document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('blur', onWindowBlur);
window.removeEventListener('focus', onWindowFocus);
scheduleCursorFrame = () => {};
suspendCursor();
if (rotateSpringAnim) { if (rotateSpringAnim) {
rotateSpringAnim.pause(); rotateSpringAnim.pause();
} }
+19 -4
View File
@@ -532,12 +532,13 @@ export function themeMediaSuspension(node: HTMLElement) {
/** /**
* Manage background video playback across document visibility changes. * Manage background video playback across document visibility changes.
* Videos continue playing when the launcher window loses focus. Muted background * Muted background videos pause when the document is hidden/minimized or the
* videos pause only when the document is hidden/minimized to save GPU decoding * launcher loses focus, which saves GPU decoding cycles behind a running game
* cycles without interrupting theme music or audio. * without interrupting unmuted theme music or audio.
*/ */
export function themeVideoLifecycle(node: HTMLVideoElement) { export function themeVideoLifecycle(node: HTMLVideoElement) {
let pausedByLifecycle = false; let pausedByLifecycle = false;
let focused = true;
const pause = () => { const pause = () => {
if (node.paused) return; if (node.paused) return;
@@ -551,12 +552,22 @@ export function themeVideoLifecycle(node: HTMLVideoElement) {
playThemeMedia(node); playThemeMedia(node);
}; };
const onBlur = () => {
focused = false;
sync();
};
const onFocus = () => {
focused = true;
sync();
};
const sync = () => { const sync = () => {
if (!node.isConnected) { if (!node.isConnected) {
document.removeEventListener('visibilitychange', sync); document.removeEventListener('visibilitychange', sync);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
return; return;
} }
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible' && focused) {
resume(); resume();
} else if (node.muted || node.volume === 0) { } else if (node.muted || node.volume === 0) {
pause(); pause();
@@ -564,11 +575,15 @@ export function themeVideoLifecycle(node: HTMLVideoElement) {
}; };
document.addEventListener('visibilitychange', sync); document.addEventListener('visibilitychange', sync);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
sync(); sync();
return { return {
destroy() { destroy() {
document.removeEventListener('visibilitychange', sync); document.removeEventListener('visibilitychange', sync);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
}, },
}; };
} }
+1 -1
View File
@@ -133,7 +133,7 @@ export const urlIsValidImage = async (url: string) => {
const request = await fetch(url); const request = await fetch(url);
if (!request.ok) return false; if (!request.ok) return false;
const contentType = request.headers.get('content-type'); const contentType = request.headers.get('content-type');
return contentType?.startsWith('image/'); return contentType?.startsWith('image/') === true;
} catch { } catch {
return false; return false;
} }
+32
View File
@@ -71,6 +71,37 @@
let { children } = $props(); 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 unlistenEditorState: (() => void) | undefined;
let unlistenRequestEditorData: (() => void) | undefined; let unlistenRequestEditorData: (() => void) | undefined;
let unlistenPresenceEditorState: (() => void) | undefined; let unlistenPresenceEditorState: (() => void) | undefined;
@@ -552,6 +583,7 @@
</AlertDialog.Root> </AlertDialog.Root>
<main <main
use:appActivity
use:themeMediaSuspension use:themeMediaSuspension
use:themeParallax={{ use:themeParallax={{
enabled: $active_custom_theme?.definition?.parallax?.enabled === true, enabled: $active_custom_theme?.definition?.parallax?.enabled === true,
+15 -4
View File
@@ -1580,6 +1580,7 @@
await getCurrentWindow().hide(); await getCurrentWindow().hide();
let presenceUpdater: number | undefined = undefined; let presenceUpdater: number | undefined = undefined;
const beatmapCoverValidity = new SvelteMap<string, boolean>();
const isPresenceConnected = await presence.isConnected(); const isPresenceConnected = await presence.isConnected();
@@ -1592,8 +1593,10 @@
return; return;
} }
if ($currentUser) { if ($currentUser) {
const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats'); const [userStats, userStatus] = await Promise.all([
const userStatus = await ezppfarm.getUserStatus($currentUser.id); ezppfarm.getUserInfo($currentUser.id, 'stats'),
ezppfarm.getUserStatus($currentUser.id),
]);
if (userStatus?.player_status.online) { if (userStatus?.player_status.online) {
let actionName = 'Idle...'; let actionName = 'Idle...';
const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode); const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode);
@@ -1648,7 +1651,15 @@
if (beatmap !== null) { if (beatmap !== null) {
beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`; beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`;
const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/[email protected]`; const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/[email protected]`;
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; if (isValidImage) beatmapCoverImage = coverCandidate;
} }
@@ -1678,7 +1689,7 @@
} }
} }
} }
}, 1000 * 2); }, 5000);
} }
await runOsu(osuPath, $patch, $gameOptimization); await runOsu(osuPath, $patch, $gameOptimization);