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:
1 parent
687ab26752
commit
c2fd265b49
8 files changed
+168
-28
No files matched your search
@@ -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,
|
||||
|
||||
@@ -17,12 +17,38 @@
|
||||
let container = $state<HTMLDivElement | undefined>(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(() => {
|
||||
|
||||
@@ -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'};
|
||||
"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
let velocityAccumX = 0;
|
||||
let velocityAccumY = 0;
|
||||
let rotateSpringAnim: ReturnType<typeof animate> | 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 (!el) return false;
|
||||
if (
|
||||
['button', 'a', 'input', 'select', 'textarea', 'label', 'option'].includes(tag) ||
|
||||
(role && ['button', 'link', 'checkbox', 'combobox', 'menuitem', 'option'].includes(role)) ||
|
||||
computed.cursor === 'pointer'
|
||||
el.closest(
|
||||
'button, a, input, select, textarea, label, option, [role="button"], [role="link"], [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"]'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
</AlertDialog.Root>
|
||||
|
||||
<main
|
||||
use:appActivity
|
||||
use:themeMediaSuspension
|
||||
use:themeParallax={{
|
||||
enabled: $active_custom_theme?.definition?.parallax?.enabled === true,
|
||||
|
||||
@@ -1580,6 +1580,7 @@
|
||||
await getCurrentWindow().hide();
|
||||
|
||||
let presenceUpdater: number | undefined = undefined;
|
||||
const beatmapCoverValidity = new SvelteMap<string, boolean>();
|
||||
|
||||
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/[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;
|
||||
}
|
||||
|
||||
@@ -1678,7 +1689,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000 * 2);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
await runOsu(osuPath, $patch, $gameOptimization);
|
||||
|
||||
Reference in new issue
Block a user