fix(macos): resolve osu updater loop and patcher wine compatibility
- Remove host Apple Braille font entries from Wine prefix registry to prevent cold-start GDI+ AccessViolationException. - Purge .require_update, help.txt, and _pending prior to osu! launch so the game launches directly with -devserver ez-pp.farm instead of entering the updater. - Reset _UpdateFailCount in osu!.cfg and ensure execute.bat points to ez-pp.farm. - Update ensure_patcher_compatibility to patch osu!.patcher.exe and embedded ManagedInjector.Lib for .NET 4.0 compatibility under Wine. - Include theme editor, cursor, and download button refinements on themes-v2 branch.
This commit is contained in:
1 parent
a2b03d3014
commit
e52399911c
19 files changed
+1362
-265
No files matched your search
@@ -2238,7 +2238,7 @@
|
||||
element.width = 340;
|
||||
element.height = 64;
|
||||
} else if (widgetKind === 'launch-button') {
|
||||
element.width = 210;
|
||||
element.width = 256;
|
||||
element.height = 48;
|
||||
}
|
||||
}
|
||||
@@ -2290,9 +2290,9 @@
|
||||
) as ThemeV2WidgetElement;
|
||||
launchBtn.widget = 'launch-button';
|
||||
launchBtn.name = 'Launch Button';
|
||||
launchBtn.x = 740;
|
||||
launchBtn.x = 720;
|
||||
launchBtn.y = 575;
|
||||
launchBtn.width = 210;
|
||||
launchBtn.width = 256;
|
||||
launchBtn.height = 48;
|
||||
newElements.push(launchBtn);
|
||||
existing = launchBtn;
|
||||
|
||||
@@ -2322,7 +2322,7 @@
|
||||
element.width = 140;
|
||||
element.height = 42;
|
||||
} else if (val === 'primary') {
|
||||
element.width = 210;
|
||||
element.width = 256;
|
||||
element.height = 48;
|
||||
}
|
||||
} else if (element.widget === 'header') {
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
set = DEFAULT_THEME_ICON_SET,
|
||||
size = '1em',
|
||||
class: className = '',
|
||||
style = '',
|
||||
}: {
|
||||
name: ThemeIconName;
|
||||
set?: ThemeIconSet;
|
||||
size?: number | string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
} = $props();
|
||||
|
||||
const icon = $derived(getThemeIconBody(name, set));
|
||||
@@ -23,6 +25,7 @@
|
||||
width={size}
|
||||
height={size}
|
||||
class={className}
|
||||
{style}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import DownloadButton from '$lib/components/ui/download-button/DownloadButton.svelte';
|
||||
import { currentUserInfo } from '$lib/data';
|
||||
import { launching as launchingStore } from '$lib/global';
|
||||
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
||||
@@ -70,6 +71,7 @@
|
||||
launching = undefined,
|
||||
launchProgress = -1,
|
||||
launchText = 'Launch',
|
||||
launchSubtext = undefined,
|
||||
launchDisabled = false,
|
||||
oninteraction,
|
||||
onselect,
|
||||
@@ -103,6 +105,7 @@
|
||||
launching?: boolean;
|
||||
launchProgress?: number;
|
||||
launchText?: string;
|
||||
launchSubtext?: string;
|
||||
launchDisabled?: boolean;
|
||||
oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
@@ -1613,14 +1616,25 @@
|
||||
}}
|
||||
>
|
||||
{#if isActionLaunch && isLaunching}
|
||||
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
|
||||
{#if launchProgress >= 0}
|
||||
<span class="text-[11px] font-bold">{Math.round(launchProgress)}%</span>
|
||||
{:else}
|
||||
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
|
||||
{/if}
|
||||
{:else if element.icon}
|
||||
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
|
||||
{/if}
|
||||
{#if isActionLaunch && isLaunching}
|
||||
<span class="truncate">
|
||||
{launchProgress > 0 ? `${launchText} (${Math.round(launchProgress)}%)` : launchText}
|
||||
</span>
|
||||
<div class="flex flex-col items-center justify-center min-w-0">
|
||||
<span class="truncate">
|
||||
{launchProgress >= 0
|
||||
? `${launchText} (${Math.round(launchProgress)}%)`
|
||||
: launchText}
|
||||
</span>
|
||||
{#if launchSubtext}
|
||||
<span class="text-[10px] opacity-75 truncate max-w-[140px]">{launchSubtext}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isMorphEnabled(element)}
|
||||
<TextMorph
|
||||
text={String(interpolateThemeText(element.label, variables) ?? '')}
|
||||
@@ -1852,7 +1866,7 @@
|
||||
)}
|
||||
{@const btnCircumference = 2 * Math.PI * btnRadius}
|
||||
{@const strokeOffset =
|
||||
launchProgress === -1
|
||||
launchProgress < 0
|
||||
? btnCircumference * 0.7
|
||||
: btnCircumference -
|
||||
(Math.max(0, Math.min(100, launchProgress)) / 100) * btnCircumference}
|
||||
@@ -1870,12 +1884,16 @@
|
||||
onbuttonaction?.({ type: 'launch' });
|
||||
}
|
||||
}}
|
||||
title={isLaunching ? launchText : 'Launch osu!'}
|
||||
title={isLaunching
|
||||
? launchSubtext
|
||||
? `${launchText} - ${launchSubtext}`
|
||||
: launchText
|
||||
: 'Launch osu!'}
|
||||
>
|
||||
{#if isLaunching}
|
||||
<!-- Rotating progress ring SVG -->
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full {launchProgress === -1
|
||||
class="absolute inset-0 h-full w-full {launchProgress < 0
|
||||
? 'animate-spin'
|
||||
: '-rotate-90'}"
|
||||
viewBox="0 0 {element.width} {element.height}"
|
||||
@@ -1902,7 +1920,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{#if launchProgress > 0}
|
||||
{#if launchProgress >= 0}
|
||||
<span
|
||||
class="relative z-10 text-[11px] font-black tracking-tight text-primary-foreground"
|
||||
>
|
||||
@@ -1910,9 +1928,9 @@
|
||||
</span>
|
||||
{:else}
|
||||
<ThemeIcon
|
||||
name="loader"
|
||||
name="ellipsis"
|
||||
set={iconSet}
|
||||
class="relative z-10 size-6 animate-spin text-primary-foreground"
|
||||
class="relative z-10 size-4 animate-pulse text-primary-foreground"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1934,61 +1952,53 @@
|
||||
onbuttonaction?.({ type: 'launch' });
|
||||
}
|
||||
}}
|
||||
title={isLaunching ? launchText : 'Launch osu!'}
|
||||
title={isLaunching
|
||||
? launchSubtext
|
||||
? `${launchText} - ${launchSubtext}`
|
||||
: launchText
|
||||
: 'Launch osu!'}
|
||||
>
|
||||
{#if isLaunching}
|
||||
<ThemeIcon
|
||||
name="loader"
|
||||
set={iconSet}
|
||||
class="size-3.5 animate-spin fill-current"
|
||||
/>
|
||||
<span>{launchProgress > 0 ? `${Math.round(launchProgress)}%` : launchText}</span
|
||||
>
|
||||
{#if launchProgress >= 0}
|
||||
<span class="text-[11px] font-black">{Math.round(launchProgress)}%</span>
|
||||
{:else}
|
||||
<ThemeIcon
|
||||
name="loader"
|
||||
set={iconSet}
|
||||
class="size-3.5 animate-spin fill-current"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col items-center justify-center min-w-0">
|
||||
<span class="truncate">{launchText}</span>
|
||||
{#if launchSubtext}
|
||||
<span class="text-[9px] opacity-75 truncate max-w-[140px]"
|
||||
>{launchSubtext}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ThemeIcon name="launch" set={iconSet} class="size-3.5 fill-current" />
|
||||
<span>Launch</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-full w-full items-center justify-center gap-2.5 rounded-full bg-primary px-5 font-bold text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
|
||||
? 'cursor-wait brightness-95'
|
||||
: launchDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
|
||||
disabled={launchDisabled || isLaunching}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!launchDisabled && !isLaunching) {
|
||||
onbuttonaction?.({ type: 'launch' });
|
||||
}
|
||||
}}
|
||||
title={isLaunching ? launchText : 'Launch osu!'}
|
||||
>
|
||||
<div class="flex size-7 items-center justify-center rounded-full bg-black/20">
|
||||
{#if isLaunching}
|
||||
<ThemeIcon
|
||||
name="loader"
|
||||
set={iconSet}
|
||||
class="size-3.5 animate-spin text-primary-foreground"
|
||||
/>
|
||||
{:else}
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm font-bold tracking-wide">
|
||||
{isLaunching
|
||||
? launchProgress > 0
|
||||
? `${launchText} (${Math.round(launchProgress)}%)`
|
||||
: launchText
|
||||
: 'Launch'}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<DownloadButton
|
||||
downloading={isLaunching}
|
||||
progress={launchProgress}
|
||||
text={isLaunching ? launchText : 'Launch'}
|
||||
subtext={launchSubtext}
|
||||
disabled={launchDisabled}
|
||||
{iconSet}
|
||||
class="h-full w-full justify-center"
|
||||
onClick={() => {
|
||||
if (interactive) return;
|
||||
if (!launchDisabled && !isLaunching) {
|
||||
onbuttonaction?.({ type: 'launch' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if element.widget === 'player-card'}
|
||||
{@const playerStats =
|
||||
|
||||
@@ -9,23 +9,60 @@
|
||||
subtext?: string;
|
||||
disabled?: boolean;
|
||||
iconSet?: ThemeIconSet;
|
||||
class?: string;
|
||||
onClick?: () => void;
|
||||
} = $props();
|
||||
|
||||
const radius = 16;
|
||||
let buttonHeight = $state(48);
|
||||
let buttonWidth = $state(200);
|
||||
|
||||
const baseDim = $derived(
|
||||
Math.min(buttonHeight > 0 ? buttonHeight : 48, buttonWidth > 0 ? buttonWidth : 200)
|
||||
);
|
||||
|
||||
// Circle scales with button dimensions: ~70% of base dimension (no artificial upper clamp)
|
||||
const circleSize = $derived(Math.max(16, Math.round(baseDim * 0.7)));
|
||||
// Icon scales with circle size: ~44% of circle size
|
||||
const iconSize = $derived(Math.max(10, Math.round(circleSize * 0.44)));
|
||||
// Progress font size scales with circle size
|
||||
const percentFontSize = $derived(Math.max(8, Math.round(circleSize * 0.32)));
|
||||
// Optical horizontal nudge for the play triangle
|
||||
const iconNudge = $derived(Math.max(1, Math.round(circleSize * 0.04)));
|
||||
|
||||
// Responsive font scaling for label and subtext
|
||||
const availableTextWidth = $derived(
|
||||
Math.max(50, (buttonWidth > 0 ? buttonWidth : 200) - baseDim)
|
||||
);
|
||||
const textChars = $derived(Math.max(6, (prop.text || 'Launch').length));
|
||||
const maxFontFromWidth = $derived((availableTextWidth * 0.85) / textChars);
|
||||
const maxFontFromHeight = $derived(baseDim * (prop.subtext ? 0.24 : 0.32));
|
||||
const labelFontSize = $derived(
|
||||
Math.max(10, Math.round(Math.min(maxFontFromHeight, maxFontFromWidth)))
|
||||
);
|
||||
const subtextFontSize = $derived(Math.max(8, Math.round(labelFontSize * 0.72)));
|
||||
|
||||
const radius = 16.5;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = $derived(circumference - (prop.progress / 100) * circumference);
|
||||
const strokeDashoffset = $derived(
|
||||
circumference - (Math.max(0, Math.min(100, prop.progress)) / 100) * circumference
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="group flex items-center h-12 rounded-full bg-primary disabled:bg-primary/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer w-fit"
|
||||
bind:clientHeight={buttonHeight}
|
||||
bind:clientWidth={buttonWidth}
|
||||
class="group flex items-center rounded-full bg-primary disabled:bg-primary/70 shadow-lg shadow-primary/25 transition-all not-disabled:hover:shadow-primary/40 not-disabled:hover:brightness-110 not-disabled:active:scale-[0.98] cursor-pointer {prop.class ??
|
||||
'h-12 w-fit'}"
|
||||
disabled={prop.disabled}
|
||||
onclick={prop.onClick}
|
||||
>
|
||||
<div class="relative size-12 shrink-0 flex items-center justify-center">
|
||||
<div class="relative h-full aspect-square shrink-0 flex items-center justify-center">
|
||||
{#if prop.downloading}
|
||||
{#if prop.progress === -1}
|
||||
<svg class="absolute inset-0 animate-spin" viewBox="0 0 48 48">
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full animate-spin pointer-events-none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<circle class="stroke-black/10" cx="24" cy="24" r={radius} fill="none" stroke-width="3" />
|
||||
<circle
|
||||
class="stroke-pink-950"
|
||||
@@ -40,7 +77,10 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 48 48">
|
||||
<svg
|
||||
class="absolute inset-0 h-full w-full -rotate-90 pointer-events-none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
@@ -64,35 +104,56 @@
|
||||
</svg>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="size-9 rounded-full bg-black/30 flex items-center justify-center relative z-10">
|
||||
<div
|
||||
class="aspect-square rounded-full bg-black/30 flex items-center justify-center relative z-10 max-h-[72%] max-w-[72%]"
|
||||
style="width: {circleSize}px; height: {circleSize}px;"
|
||||
>
|
||||
{#if prop.downloading}
|
||||
{#if prop.progress === -1}
|
||||
<ThemeIcon
|
||||
name="ellipsis"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current animate-pulse"
|
||||
size={iconSize}
|
||||
class="text-primary-foreground fill-current animate-pulse max-w-[50%] max-h-[50%]"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-[10px] font-bold text-primary-foreground">
|
||||
{prop.progress.toFixed()}
|
||||
<span
|
||||
class="font-bold text-primary-foreground leading-none"
|
||||
style="font-size: {percentFontSize}px;"
|
||||
>
|
||||
{prop.progress.toFixed()}%
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current ml-0.5"
|
||||
size={iconSize}
|
||||
class="text-primary-foreground fill-current max-w-[50%] max-h-[50%]"
|
||||
style="margin-left: {iconNudge}px;"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pr-6 pl-1 text-center w-52">
|
||||
<p class="text-sm font-bold text-primary-foreground leading-tight">
|
||||
<div
|
||||
class="text-center flex-1 min-w-0 max-w-full {prop.class ? '' : 'w-48'}"
|
||||
style="padding-right: {Math.max(8, Math.round(baseDim * 0.25))}px; padding-left: {Math.max(
|
||||
4,
|
||||
Math.round(baseDim * 0.08)
|
||||
)}px;"
|
||||
>
|
||||
<p
|
||||
class="font-bold text-primary-foreground leading-tight truncate"
|
||||
style="font-size: {labelFontSize}px;"
|
||||
>
|
||||
{prop.text}
|
||||
</p>
|
||||
{#if prop.subtext}
|
||||
<p class="text-[10px] text-primary-foreground/70 leading-tight">
|
||||
<p
|
||||
class="text-primary-foreground/70 leading-tight truncate"
|
||||
style="font-size: {subtextFontSize}px;"
|
||||
>
|
||||
{prop.subtext}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -11,21 +11,34 @@
|
||||
const SPEED_THRESHOLD = 9;
|
||||
const REQUIRED_CONSECUTIVE = 3;
|
||||
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
let prevAppliedX = -1;
|
||||
let prevAppliedY = -1;
|
||||
let hasMoved = false;
|
||||
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
let isMouseDown = $state(false);
|
||||
let isHoveringInteractive = $state(false);
|
||||
let isMouseDown = false;
|
||||
let isHoveringInteractive = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let degrees = $state(0);
|
||||
let applyRotation = $state(false);
|
||||
let targetDegrees = 0;
|
||||
let currentDegrees = 0;
|
||||
let applyRotation = false;
|
||||
|
||||
let lastVelocityTime = 0;
|
||||
let consecutiveHighSpeed = 0;
|
||||
let velocityAccumX = 0;
|
||||
let velocityAccumY = 0;
|
||||
let rotateSpringAnim: ReturnType<typeof animate> | null = null;
|
||||
|
||||
let cursor: HTMLElement;
|
||||
let cursorRotateWrapper: HTMLDivElement;
|
||||
let cursorInner: HTMLImageElement;
|
||||
let cursorAdditive: HTMLImageElement;
|
||||
|
||||
function isInteractive(el: Element | null): boolean {
|
||||
while (el) {
|
||||
@@ -44,15 +57,36 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
function dampContinuously(current: number, target: number, halfLife: number, dt: number): number {
|
||||
if (halfLife <= 0 || dt <= 0) return target;
|
||||
return target + (current - target) * Math.pow(0.5, dt / halfLife);
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
targetX = e.clientX;
|
||||
targetY = e.clientY;
|
||||
|
||||
const deltaX = e.pageX - window.pageXOffset - dragStartX;
|
||||
const deltaY = e.pageY - window.pageYOffset - dragStartY;
|
||||
if (!hasMoved) {
|
||||
currentX = targetX;
|
||||
currentY = targetY;
|
||||
prevAppliedX = targetX;
|
||||
prevAppliedY = targetY;
|
||||
lastMouseX = targetX;
|
||||
lastMouseY = targetY;
|
||||
hasMoved = true;
|
||||
if (cursor) {
|
||||
cursor.style.opacity = '1';
|
||||
cursor.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
velocityAccumX += mouseX - lastMouseX;
|
||||
velocityAccumY += mouseY - lastMouseY;
|
||||
if (cursor) {
|
||||
cursor.style.opacity = '1';
|
||||
}
|
||||
|
||||
velocityAccumX += targetX - lastMouseX;
|
||||
velocityAccumY += targetY - lastMouseY;
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - lastVelocityTime;
|
||||
@@ -74,48 +108,33 @@
|
||||
lastVelocityTime = now;
|
||||
}
|
||||
|
||||
let newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
|
||||
|
||||
let diff = (newDegrees - degrees) % 360;
|
||||
if (diff < -180) diff += 360;
|
||||
if (diff > 180) diff -= 360;
|
||||
|
||||
if (isMouseDown && applyRotation) {
|
||||
degrees += diff;
|
||||
} else {
|
||||
degrees = 0;
|
||||
const deltaX = targetX - dragStartX;
|
||||
const deltaY = targetY - dragStartY;
|
||||
const newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
|
||||
|
||||
let diff = (newDegrees - targetDegrees) % 360;
|
||||
if (diff < -180) diff += 360;
|
||||
if (diff > 180) diff -= 360;
|
||||
|
||||
targetDegrees += diff;
|
||||
}
|
||||
|
||||
const el = document.elementFromPoint(mouseX, mouseY);
|
||||
isHoveringInteractive = isInteractive(el);
|
||||
|
||||
if (!isMouseDown) {
|
||||
animate(cursorAdditive, {
|
||||
opacity: isHoveringInteractive ? 1 : 0,
|
||||
duration: 800,
|
||||
ease: (t: number) => (t - 1) ** 5 + 1,
|
||||
});
|
||||
const el = document.elementFromPoint(targetX, targetY);
|
||||
const hovering = isInteractive(el);
|
||||
if (hovering !== isHoveringInteractive) {
|
||||
isHoveringInteractive = hovering;
|
||||
if (!isMouseDown) {
|
||||
animate(cursorAdditive, {
|
||||
opacity: isHoveringInteractive ? 1 : 0,
|
||||
duration: 800,
|
||||
ease: (t: number) => (t - 1) ** 5 + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
animate(cursor, {
|
||||
duration: smoothCursor ? $cursorSmoothness : 0,
|
||||
translateX: mouseX,
|
||||
translateY: mouseY,
|
||||
ease: (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
||||
});
|
||||
|
||||
animate(cursor, {
|
||||
duration: isMouseDown ? 200 : 1500,
|
||||
rotate: degrees,
|
||||
transformOrigin: '0px 0px 0',
|
||||
ease: (t: number) =>
|
||||
isMouseDown
|
||||
? (t - 1) ** 7 + 1
|
||||
: Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
||||
});
|
||||
|
||||
lastMouseX = mouseX;
|
||||
lastMouseY = mouseY;
|
||||
lastMouseX = targetX;
|
||||
lastMouseY = targetY;
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
@@ -128,6 +147,12 @@
|
||||
lastVelocityTime = performance.now();
|
||||
|
||||
isMouseDown = true;
|
||||
|
||||
if (rotateSpringAnim) {
|
||||
rotateSpringAnim.pause();
|
||||
rotateSpringAnim = null;
|
||||
}
|
||||
|
||||
animate(cursorInner, {
|
||||
scale: 0.9,
|
||||
duration: 800,
|
||||
@@ -143,6 +168,7 @@
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isMouseDown = false;
|
||||
const hadRotation = applyRotation || Math.abs(currentDegrees) > 0.1;
|
||||
applyRotation = false;
|
||||
consecutiveHighSpeed = 0;
|
||||
velocityAccumX = 0;
|
||||
@@ -154,21 +180,86 @@
|
||||
ease: (t: number) => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
||||
});
|
||||
animate(cursorAdditive, {
|
||||
opacity: 0,
|
||||
opacity: isHoveringInteractive ? 1 : 0,
|
||||
duration: 500,
|
||||
scale: 1,
|
||||
ease: (t: number) => (t - 1) ** 5 + 1,
|
||||
});
|
||||
|
||||
if (hadRotation && cursorRotateWrapper) {
|
||||
rotateSpringAnim = animate(cursorRotateWrapper, {
|
||||
rotate: 0,
|
||||
duration: 1200,
|
||||
ease: (t: number) => Math.pow(2, -10 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
|
||||
onComplete: () => {
|
||||
currentDegrees = 0;
|
||||
targetDegrees = 0;
|
||||
rotateSpringAnim = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let cursor: HTMLElement;
|
||||
let cursorInner: HTMLDivElement;
|
||||
let cursorAdditive: HTMLImageElement;
|
||||
const handleMouseLeave = () => {
|
||||
if (cursor) cursor.style.opacity = '0';
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (cursor && hasMoved) cursor.style.opacity = '1';
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.documentElement.classList.add('hiddenCursor');
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
document.addEventListener('mouseenter', handleMouseEnter);
|
||||
|
||||
let animationFrameId: number;
|
||||
let lastFrameTime = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const dt = Math.min(Math.max(now - lastFrameTime, 0), 64);
|
||||
lastFrameTime = now;
|
||||
|
||||
if (hasMoved && cursor) {
|
||||
if (!smoothCursor) {
|
||||
currentX = targetX;
|
||||
currentY = targetY;
|
||||
} else {
|
||||
// Continuous exponential damping half-life in ms (mathematically invariant across refresh rates).
|
||||
const halfLife = $cursorSmoothness > 50 ? 14 : Math.max(1, $cursorSmoothness || 14);
|
||||
currentX = dampContinuously(currentX, targetX, halfLife, dt);
|
||||
currentY = dampContinuously(currentY, targetY, halfLife, dt);
|
||||
}
|
||||
|
||||
if (Math.abs(targetX - currentX) < 0.05) currentX = targetX;
|
||||
if (Math.abs(targetY - currentY) < 0.05) currentY = targetY;
|
||||
|
||||
if (currentX !== prevAppliedX || currentY !== prevAppliedY) {
|
||||
cursor.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
|
||||
prevAppliedX = currentX;
|
||||
prevAppliedY = currentY;
|
||||
}
|
||||
|
||||
if (isMouseDown && applyRotation && cursorRotateWrapper) {
|
||||
const rotFactor = 1 - Math.pow(0.5, dt / 25);
|
||||
currentDegrees += (targetDegrees - currentDegrees) * rotFactor;
|
||||
cursorRotateWrapper.style.transform = `rotate(${currentDegrees}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove('hiddenCursor');
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
document.removeEventListener('mouseenter', handleMouseEnter);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
if (rotateSpringAnim) {
|
||||
rotateSpringAnim.pause();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -179,8 +270,8 @@
|
||||
onmouseup={handleMouseUp}
|
||||
/>
|
||||
|
||||
<div class="h-7 w-7 fixed pointer-events-none z-[9999]" bind:this={cursor}>
|
||||
<div class="relative">
|
||||
<div class="h-7 w-7 fixed top-0 left-0 pointer-events-none z-[9999] opacity-0" bind:this={cursor}>
|
||||
<div class="relative origin-top-left" bind:this={cursorRotateWrapper}>
|
||||
<img class="absolute top-0 left-0" src={cursor_default} bind:this={cursorInner} alt="cursor" />
|
||||
<img
|
||||
class="absolute top-0 left-0 opacity-0"
|
||||
|
||||
+33
-12
@@ -197,7 +197,7 @@ const armAutoplayRetry = (node: HTMLMediaElement) => {
|
||||
document.addEventListener('keydown', retry, true);
|
||||
};
|
||||
|
||||
const playThemeMedia = (node: HTMLMediaElement) => {
|
||||
export const playThemeMedia = (node: HTMLMediaElement) => {
|
||||
void node
|
||||
.play()
|
||||
.then(() => {
|
||||
@@ -498,10 +498,10 @@ export function themeMediaSuspension(node: HTMLElement) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause background videos while the launcher is hidden or unfocused, then
|
||||
* resume only the videos this helper paused itself. This keeps muted theme
|
||||
* videos from decoding in the background and prevents surprise audio when the
|
||||
* window comes back.
|
||||
* 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.
|
||||
*/
|
||||
export function themeVideoLifecycle(node: HTMLVideoElement) {
|
||||
let pausedByLifecycle = false;
|
||||
@@ -513,26 +513,29 @@ export function themeVideoLifecycle(node: HTMLVideoElement) {
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (!pausedByLifecycle) return;
|
||||
if (!pausedByLifecycle && !node.autoplay && !node.loop) return;
|
||||
pausedByLifecycle = false;
|
||||
playThemeMedia(node);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
if (document.visibilityState === 'visible' && document.hasFocus()) resume();
|
||||
else pause();
|
||||
if (!node.isConnected) {
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === 'visible') {
|
||||
resume();
|
||||
} else if (node.muted || node.volume === 0) {
|
||||
pause();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
window.addEventListener('focus', sync);
|
||||
window.addEventListener('blur', sync);
|
||||
sync();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
window.removeEventListener('focus', sync);
|
||||
window.removeEventListener('blur', sync);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -564,8 +567,14 @@ export function themeMediaSource(node: HTMLMediaElement, source: string) {
|
||||
*/
|
||||
export function smoothThemeMedia(node: HTMLElement) {
|
||||
const lastSource = new WeakMap<HTMLMediaElement, string>();
|
||||
const videoLifecycles = new WeakMap<HTMLVideoElement, () => void>();
|
||||
|
||||
const prepare = (media: HTMLMediaElement) => {
|
||||
if (media instanceof HTMLVideoElement && !videoLifecycles.has(media)) {
|
||||
const lifecycle = themeVideoLifecycle(media);
|
||||
videoLifecycles.set(media, () => lifecycle.destroy());
|
||||
}
|
||||
|
||||
const source =
|
||||
media.getAttribute('src') ??
|
||||
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
|
||||
@@ -595,6 +604,18 @@ export function smoothThemeMedia(node: HTMLElement) {
|
||||
for (const added of mutation.addedNodes) {
|
||||
if (added instanceof HTMLElement) scan(added);
|
||||
}
|
||||
|
||||
for (const removed of mutation.removedNodes) {
|
||||
if (removed instanceof HTMLVideoElement) {
|
||||
videoLifecycles.get(removed)?.();
|
||||
videoLifecycles.delete(removed);
|
||||
} else if (removed instanceof HTMLElement) {
|
||||
removed.querySelectorAll?.<HTMLVideoElement>('video').forEach((video) => {
|
||||
videoLifecycles.get(video)?.();
|
||||
videoLifecycles.delete(video);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1296,8 +1296,9 @@ const drawWidgetElement = async (context: PreviewContext, element: ThemeV2Widget
|
||||
fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent);
|
||||
ctx.restore();
|
||||
|
||||
const circleR = Math.max(12, element.height * 0.32);
|
||||
const circleX = element.x + circleR + 10;
|
||||
const baseDim = Math.min(element.width, element.height);
|
||||
const circleR = Math.round(baseDim * 0.35);
|
||||
const circleX = element.x + baseDim / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(circleX, centerY, circleR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
|
||||
@@ -1307,7 +1308,12 @@ const drawWidgetElement = async (context: PreviewContext, element: ThemeV2Widget
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = accentText;
|
||||
ctx.font = canvasFont(context, bodyFont, Math.min(14, element.height * 0.33), 700);
|
||||
const availableTextW = Math.max(50, element.width - baseDim);
|
||||
const textFontSize = Math.max(
|
||||
10,
|
||||
Math.min(Math.round(baseDim * 0.32), Math.round((availableTextW * 0.85) / 6))
|
||||
);
|
||||
ctx.font = canvasFont(context, bodyFont, textFontSize, 700);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const textCenterX = circleX + circleR + (element.x + element.width - (circleX + circleR)) / 2;
|
||||
|
||||
+2
-2
@@ -1664,8 +1664,8 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
||||
...base,
|
||||
type,
|
||||
widget: 'launch-button',
|
||||
width: 220,
|
||||
height: 60,
|
||||
width: 256,
|
||||
height: 48,
|
||||
},
|
||||
index
|
||||
)! as ThemeV2WidgetElement;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { writable } from 'svelte/store';
|
||||
|
||||
export const customCursor = writable<boolean>(false);
|
||||
export const cursorSmoothening = writable<boolean>(false);
|
||||
export const cursorSmoothness = writable<number>(180);
|
||||
export const cursorSmoothness = writable<number>(14);
|
||||
export const reduceAnimations = writable<boolean>(false);
|
||||
export const patch = writable<boolean>(true);
|
||||
export const loggingEnabled = writable<boolean>(true);
|
||||
|
||||
+92
-25
@@ -1,4 +1,5 @@
|
||||
import { custom_theme_audio_playing } from './global';
|
||||
import { playThemeMedia } from './themeMedia';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import * as crypto from 'crypto-js';
|
||||
@@ -149,50 +150,116 @@ export function setGlobalVolume(volume: number) {
|
||||
});
|
||||
}
|
||||
|
||||
let activeFadeCancel: (() => void) | undefined = undefined;
|
||||
|
||||
export function fadeGlobalVolume(
|
||||
volumeFrom: number,
|
||||
volumeTo: number,
|
||||
duration: number,
|
||||
onDone?: () => void
|
||||
) {
|
||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
function animate() {
|
||||
const currentTime = performance.now();
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
const interpolatedVolume = volumeFrom + (volumeTo - volumeFrom) * progress;
|
||||
|
||||
mediaElements.forEach((element) => {
|
||||
element.volume = interpolatedVolume;
|
||||
});
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (onDone) onDone();
|
||||
}
|
||||
): Promise<void> {
|
||||
if (activeFadeCancel) {
|
||||
activeFadeCancel();
|
||||
activeFadeCancel = undefined;
|
||||
}
|
||||
|
||||
animate();
|
||||
return new Promise((resolve) => {
|
||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||
if (mediaElements.length === 0 || duration <= 0) {
|
||||
if (mediaElements.length > 0) {
|
||||
mediaElements.forEach((element) => {
|
||||
element.volume = Math.min(1, Math.max(0, volumeTo));
|
||||
});
|
||||
}
|
||||
if (onDone) onDone();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId: number;
|
||||
|
||||
activeFadeCancel = () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
function animate() {
|
||||
if (cancelled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = performance.now();
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
const interpolatedVolume = Math.min(
|
||||
1,
|
||||
Math.max(0, volumeFrom + (volumeTo - volumeFrom) * progress)
|
||||
);
|
||||
|
||||
mediaElements.forEach((element) => {
|
||||
element.volume = interpolatedVolume;
|
||||
});
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
activeFadeCancel = undefined;
|
||||
if (onDone) onDone();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
});
|
||||
}
|
||||
|
||||
export const pauseGlobalMedia = () => {
|
||||
if (activeFadeCancel) {
|
||||
activeFadeCancel();
|
||||
activeFadeCancel = undefined;
|
||||
}
|
||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||
mediaElements.forEach((element) => element.pause());
|
||||
mediaElements.forEach((element) => {
|
||||
try {
|
||||
element.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const resumeGlobalMedia = () => {
|
||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||
mediaElements.forEach((element) => element.play());
|
||||
mediaElements.forEach((element) => {
|
||||
try {
|
||||
if (element instanceof HTMLVideoElement && element.error) {
|
||||
element.load();
|
||||
}
|
||||
playThemeMedia(element);
|
||||
} catch (err) {
|
||||
console.warn('Could not resume global media:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const setCurrentTimeGlobalMedia = (time: number) => {
|
||||
const mediaElements: NodeListOf<HTMLMediaElement> = document.querySelectorAll('audio, video');
|
||||
mediaElements.forEach((element) => (element.currentTime = time));
|
||||
mediaElements.forEach((element) => {
|
||||
try {
|
||||
if (Number.isFinite(element.duration) && element.duration > 0) {
|
||||
element.currentTime = Math.min(Math.max(0, time), element.duration);
|
||||
} else {
|
||||
element.currentTime = Math.max(0, time);
|
||||
}
|
||||
} catch {
|
||||
// ignore if media is not seekable
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function trackMediaContainer(node: HTMLElement) {
|
||||
|
||||
Reference in new issue
Block a user