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 */
|
/* 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();
|
|
||||||
const role = el.getAttribute('role');
|
|
||||||
const computed = getComputedStyle(el);
|
|
||||||
if (
|
if (
|
||||||
['button', 'a', 'input', 'select', 'textarea', 'label', 'option'].includes(tag) ||
|
el.closest(
|
||||||
(role && ['button', 'link', 'checkbox', 'combobox', 'menuitem', 'option'].includes(role)) ||
|
'button, a, input, select, textarea, label, option, [role="button"], [role="link"], [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"]'
|
||||||
computed.cursor === 'pointer'
|
)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
el = el.parentElement;
|
return getComputedStyle(el).cursor === 'pointer';
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user